From 3f2cd9345f23b7b3e2b79b92ba353229effb6a23 Mon Sep 17 00:00:00 2001 From: ZHUO Xu Date: Wed, 5 Aug 2026 10:25:17 +0800 Subject: [PATCH] refactor: migrate to Streamdown and restore GPU rendering for performance, reduce Gateway restarts via optimized OpenClaw config delivery (#1224) --- AGENTS.md | 3 + README.ja-JP.md | 18 +- README.md | 18 +- README.zh-CN.md | 18 +- electron/gateway/client.ts | 333 - electron/gateway/config-delivery.ts | 289 + electron/gateway/config-sync.ts | 16 +- electron/gateway/event-dispatch.ts | 6 +- electron/gateway/manager.ts | 148 - electron/gateway/reload-policy.ts | 63 - electron/gateway/rpc-backpressure.ts | 101 - electron/gateway/ws-trace.ts | 11 +- electron/main/index.ts | 18 +- electron/main/ipc-handlers.ts | 112 +- electron/services/agents-api.ts | 36 +- electron/services/channels-api.ts | 104 +- electron/services/chat-api.ts | 106 - electron/services/gateway-api.ts | 13 +- electron/services/providers-api.ts | 8 +- .../providers/provider-runtime-sync.ts | 97 +- electron/utils/agent-config.ts | 308 +- electron/utils/channel-config.ts | 350 +- electron/utils/openclaw-auth.ts | 491 +- electron/utils/openclaw-image-generation.ts | 49 +- electron/utils/openclaw-proxy.ts | 32 +- electron/utils/plugin-install.ts | 98 +- electron/utils/skill-config.ts | 76 +- electron/utils/wechat-login.ts | 19 +- harness/reference/acp-chat.md | 8 +- .../electron-rendering-performance.md | 29 + harness/reference/markdown-rendering.md | 80 + harness/reference/openclaw-config-delivery.md | 21 + .../reference/sidebar-session-attention.md | 2 +- .../specs/rules/acp-chat-state-and-history.md | 2 +- .../rules/electron-rendering-performance.md | 19 + ...rkdown-rendering-safety-and-performance.md | 23 + .../specs/rules/openclaw-config-delivery.md | 27 + .../specs/scenarios/acp-chat-experience.md | 13 +- .../chat-workspace-and-navigation.md | 15 +- .../gateway-backend-communication.md | 7 +- .../scenarios/gateway-startup-diagnostics.md | 6 +- harness/specs/tasks/acp-media-attachments.md | 5 +- harness/specs/tasks/acp-native-chat.md | 12 +- .../tasks/add-chat-performance-diagnostics.md | 63 + .../apply-acp-stream-updates-immediately.md | 43 + .../chat-tool-events-runtime-pipeline.md | 71 - harness/specs/tasks/chat-workspace-context.md | 4 +- ...custom-provider-context-window-defaults.md | 1 - .../delete-unavailable-chat-workspace.md | 6 +- .../specs/tasks/fix-acp-sidebar-title-race.md | 4 +- ...ix-chat-history-gateway-timeout.example.md | 26 - .../tasks/fix-chat-history-gateway-timeout.md | 62 - .../fix-chat-stuck-processing-tool-results.md | 37 - ...ix-first-chat-no-response-fallback-poll.md | 34 - .../fix-gateway-agent-phase-end-state.md | 37 - ...x-gateway-ready-session-catalog-reload.md} | 17 +- .../fix-image-generation-message-delivery.md | 32 +- .../tasks/fix-target-agent-first-send.md | 1 - .../specs/tasks/hard-delete-session-jsonl.md | 7 +- ...-session-catalog-reconciliation.example.md | 29 + .../specs/tasks/office-document-preview.md | 5 +- harness/specs/tasks/remove-pre-acp-chat.md | 140 + .../tasks/render-cron-run-live-status.md | 22 +- ...place-markdown-renderer-with-streamdown.md | 88 + .../restore-hardware-accelerated-rendering.md | 53 + .../specs/tasks/sidebar-session-attention.md | 5 +- .../use-openclaw-native-config-delivery.md | 78 + package.json | 13 +- pnpm-lock.yaml | 1247 +++- shared/acp-chat/contract-assertions.ts | 1 - shared/chat/types.ts | 81 +- shared/host-api/contract.ts | 13 - shared/i18n/locales/en/chat.json | 55 +- shared/i18n/locales/en/common.json | 3 + shared/i18n/locales/ja/chat.json | 55 +- shared/i18n/locales/ja/common.json | 3 + shared/i18n/locales/ru/chat.json | 55 +- shared/i18n/locales/ru/common.json | 3 + shared/i18n/locales/zh/chat.json | 55 +- shared/i18n/locales/zh/common.json | 3 + skills-lock.json | 11 + .../file-preview/FilePreviewBody.tsx | 182 +- .../file-preview/GeneratedFilesPanel.tsx | 125 - .../file-preview/MarkdownPreview.tsx | 167 +- .../file-preview/WorkspaceBrowserBody.tsx | 2 +- src/components/file-preview/types.ts | 29 +- src/components/layout/Sidebar.tsx | 28 +- src/components/markdown/streamdown-config.ts | 38 + src/lib/acp/timeline-groups.ts | 12 +- src/lib/generated-files.ts | 353 - src/lib/host-api.ts | 3 - src/main.tsx | 1 + src/pages/Chat/AcpAssistantTurn.tsx | 15 +- src/pages/Chat/AcpMessageSegment.tsx | 160 +- src/pages/Chat/AcpTimeline.tsx | 46 +- src/pages/Chat/ChatInput.tsx | 6 +- src/pages/Chat/ChatMessage.tsx | 919 --- src/pages/Chat/ChatToolbar.tsx | 2 +- src/pages/Chat/ExecutionGraphCard.tsx | 320 - src/pages/Chat/image-generation-status.ts | 193 - src/pages/Chat/index.tsx | 44 +- src/pages/Chat/message-utils.ts | 578 -- src/pages/Chat/task-visualization.ts | 598 -- src/stores/baseline-cache.ts | 98 - src/stores/chat.ts | 5650 +++-------------- src/stores/chat/helpers.ts | 1959 ------ src/stores/chat/history-actions.ts | 427 -- src/stores/chat/history-rpc-params.ts | 78 - src/stores/chat/history-startup-retry.ts | 98 - .../chat/history-transcript-fallback.ts | 15 - src/stores/chat/history-transcript-hydrate.ts | 26 - src/stores/chat/history-transcript-merge.ts | 114 - src/stores/chat/internal.ts | 76 - src/stores/chat/runtime-actions.ts | 12 - src/stores/chat/runtime-event-actions.ts | 81 - src/stores/chat/runtime-event-handlers.ts | 364 -- src/stores/chat/runtime-graph.ts | 200 - src/stores/chat/runtime-send-actions.ts | 315 - src/stores/chat/runtime-ui-actions.ts | 23 - src/stores/chat/session-actions.ts | 585 -- src/stores/chat/session-history-actions.ts | 10 - src/stores/chat/store-api.ts | 18 - src/stores/gateway.ts | 258 - src/styles/globals.css | 122 +- tailwind.config.js | 4 + tests/e2e/chat-acp-inline-timeline.spec.ts | 34 +- ...c.ts => chat-acp-process-timeline.spec.ts} | 7 +- tests/e2e/chat-file-changes.spec.ts | 4 - tests/e2e/chat-history-rpc.ts | 11 - tests/e2e/chat-history-startup-retry.spec.ts | 158 - tests/e2e/chat-model-picker.spec.ts | 6 - tests/e2e/chat-new-session-date.spec.ts | 25 - tests/e2e/chat-run-state-events.spec.ts | 10 +- .../chat-sidebar-session-attention.spec.ts | 10 - tests/e2e/chat-skill-trigger-i18n.spec.ts | 16 - tests/e2e/chat-streamdown-rendering.spec.ts | 269 + tests/e2e/chat-table-header-light.spec.ts | 48 +- tests/e2e/chat-workspace-context.spec.ts | 10 - tests/e2e/cron-run-live-status.spec.ts | 5 - tests/e2e/dialog-transitions.spec.ts | 23 +- tests/e2e/fixtures/electron.ts | 86 +- tests/e2e/fixtures/web-browser.ts | 1 - tests/e2e/gateway-lifecycle.spec.ts | 2 +- tests/e2e/hardware-acceleration.spec.ts | 50 + tests/e2e/markdown-file-preview.spec.ts | 209 + tests/e2e/office-document-preview.spec.ts | 23 +- tests/e2e/renderer-performance.spec.ts | 438 ++ tests/unit/acp-chat-components.test.tsx | 277 +- tests/unit/acp-chat-store.test.ts | 44 + tests/unit/acp-host-contract.test.ts | 1 - tests/unit/acp-timeline-groups.test.ts | 15 + tests/unit/agent-config.test.ts | 240 +- tests/unit/channel-config.test.ts | 229 +- ....tsx => chat-acp-inline-timeline.test.tsx} | 12 +- tests/unit/chat-acp-page.test.tsx | 206 +- .../unit/chat-artifact-panel-layout.test.tsx | 19 - .../unit/chat-assistant-media-display.test.ts | 58 - tests/unit/chat-event-dedupe.test.ts | 140 - tests/unit/chat-helpers-enrichment.test.ts | 597 -- tests/unit/chat-helpers-extract-paths.test.ts | 122 - .../chat-helpers-lifecycle-predicates.test.ts | 265 - tests/unit/chat-history-actions.test.ts | 907 --- .../chat-history-reply-while-sending.test.tsx | 10 - .../unit/chat-internal-message-filter.test.ts | 86 - .../chat-internal-sentinel-display.test.ts | 47 - tests/unit/chat-leading-orphan-tools.test.tsx | 11 - tests/unit/chat-load-sessions-startup.test.ts | 995 +-- tests/unit/chat-message.test.tsx | 515 -- tests/unit/chat-optimistic-match.test.ts | 172 - tests/unit/chat-question-directory.test.tsx | 30 +- .../unit/chat-runtime-event-handlers.test.ts | 439 -- tests/unit/chat-session-actions.test.ts | 482 -- tests/unit/chat-session-management.test.ts | 331 + tests/unit/chat-store-history-retry.test.ts | 2060 ------ .../chat-store-session-label-fetch.test.ts | 284 +- tests/unit/chat-target-routing.test.ts | 227 - .../unit/chat-tool-card-suppression.test.tsx | 13 +- tests/unit/chat-toolbar.test.tsx | 60 + tests/unit/chat-user-message-display.test.ts | 58 - tests/unit/config-mutex.test.ts | 42 + .../unit/execution-graph-card-badge.test.tsx | 172 - tests/unit/file-preview-body.test.tsx | 2 +- tests/unit/gateway-bisection-0d794cd.test.ts | 263 - tests/unit/gateway-config-delivery.test.ts | 468 ++ tests/unit/gateway-event-dispatch.test.ts | 32 +- tests/unit/gateway-events.test.ts | 2297 +------ .../unit/gateway-manager-diagnostics.test.ts | 2 +- ...eway-manager-reload-policy-refresh.test.ts | 103 - tests/unit/gateway-reload-policy.test.ts | 150 - tests/unit/gateway-rpc-backpressure.test.ts | 62 - tests/unit/gateway-rpc-test-utils.ts | 42 - tests/unit/gateway-ws-trace.test.ts | 18 +- tests/unit/generated-files-panel.test.tsx | 127 - tests/unit/generated-files.test.ts | 181 +- tests/unit/harness-specs.test.ts | 74 +- tests/unit/history-rpc-params.test.ts | 68 - tests/unit/history-startup-retry.test.ts | 11 - ...istory-transcript-fa3ccd85-fixture.test.ts | 131 - tests/unit/history-transcript-merge.test.ts | 51 - tests/unit/host-api-facade.test.ts | 18 +- tests/unit/host-services.test.ts | 329 +- tests/unit/image-generation-status.test.ts | 239 - tests/unit/main-hardware-acceleration.test.ts | 12 + tests/unit/markdown-preview.test.tsx | 82 + tests/unit/openclaw-auth.test.ts | 234 +- tests/unit/openclaw-image-generation.test.ts | 125 + tests/unit/openclaw-proxy.test.ts | 49 +- tests/unit/plugin-install.test.ts | 111 +- tests/unit/preinstalled-skill.test.ts | 83 + tests/unit/provider-runtime-sync.test.ts | 54 +- tests/unit/run-segment-indices.test.ts | 123 - tests/unit/sanitize-config.test.ts | 5 +- tests/unit/session-label-fetch.test.ts | 181 - tests/unit/sidebar-session-buckets.test.ts | 60 +- tests/unit/skill-config.test.ts | 121 + tests/unit/stores.test.ts | 12 +- tests/unit/streamdown-config.test.tsx | 68 + tests/unit/task-visualization.test.ts | 504 -- tests/unit/use-new-chat-action.test.tsx | 4 +- tests/unit/wechat-login.test.ts | 39 + 220 files changed, 9856 insertions(+), 27317 deletions(-) delete mode 100644 electron/gateway/client.ts create mode 100644 electron/gateway/config-delivery.ts delete mode 100644 electron/gateway/reload-policy.ts delete mode 100644 electron/gateway/rpc-backpressure.ts create mode 100644 harness/reference/electron-rendering-performance.md create mode 100644 harness/reference/markdown-rendering.md create mode 100644 harness/reference/openclaw-config-delivery.md create mode 100644 harness/specs/rules/electron-rendering-performance.md create mode 100644 harness/specs/rules/markdown-rendering-safety-and-performance.md create mode 100644 harness/specs/rules/openclaw-config-delivery.md create mode 100644 harness/specs/tasks/add-chat-performance-diagnostics.md create mode 100644 harness/specs/tasks/apply-acp-stream-updates-immediately.md delete mode 100644 harness/specs/tasks/chat-tool-events-runtime-pipeline.md delete mode 100644 harness/specs/tasks/fix-chat-history-gateway-timeout.example.md delete mode 100644 harness/specs/tasks/fix-chat-history-gateway-timeout.md delete mode 100644 harness/specs/tasks/fix-chat-stuck-processing-tool-results.md delete mode 100644 harness/specs/tasks/fix-first-chat-no-response-fallback-poll.md delete mode 100644 harness/specs/tasks/fix-gateway-agent-phase-end-state.md rename harness/specs/tasks/{fix-gateway-ready-chat-history-reload.md => fix-gateway-ready-session-catalog-reload.md} (64%) create mode 100644 harness/specs/tasks/maintain-session-catalog-reconciliation.example.md create mode 100644 harness/specs/tasks/remove-pre-acp-chat.md create mode 100644 harness/specs/tasks/replace-markdown-renderer-with-streamdown.md create mode 100644 harness/specs/tasks/restore-hardware-accelerated-rendering.md create mode 100644 harness/specs/tasks/use-openclaw-native-config-delivery.md create mode 100644 skills-lock.json delete mode 100644 src/components/file-preview/GeneratedFilesPanel.tsx create mode 100644 src/components/markdown/streamdown-config.ts delete mode 100644 src/pages/Chat/ChatMessage.tsx delete mode 100644 src/pages/Chat/ExecutionGraphCard.tsx delete mode 100644 src/pages/Chat/image-generation-status.ts delete mode 100644 src/pages/Chat/message-utils.ts delete mode 100644 src/pages/Chat/task-visualization.ts delete mode 100644 src/stores/baseline-cache.ts delete mode 100644 src/stores/chat/helpers.ts delete mode 100644 src/stores/chat/history-actions.ts delete mode 100644 src/stores/chat/history-rpc-params.ts delete mode 100644 src/stores/chat/history-startup-retry.ts delete mode 100644 src/stores/chat/history-transcript-fallback.ts delete mode 100644 src/stores/chat/history-transcript-hydrate.ts delete mode 100644 src/stores/chat/history-transcript-merge.ts delete mode 100644 src/stores/chat/internal.ts delete mode 100644 src/stores/chat/runtime-actions.ts delete mode 100644 src/stores/chat/runtime-event-actions.ts delete mode 100644 src/stores/chat/runtime-event-handlers.ts delete mode 100644 src/stores/chat/runtime-graph.ts delete mode 100644 src/stores/chat/runtime-send-actions.ts delete mode 100644 src/stores/chat/runtime-ui-actions.ts delete mode 100644 src/stores/chat/session-actions.ts delete mode 100644 src/stores/chat/session-history-actions.ts delete mode 100644 src/stores/chat/store-api.ts rename tests/e2e/{chat-task-visualizer.spec.ts => chat-acp-process-timeline.spec.ts} (95%) delete mode 100644 tests/e2e/chat-history-rpc.ts delete mode 100644 tests/e2e/chat-history-startup-retry.spec.ts create mode 100644 tests/e2e/chat-streamdown-rendering.spec.ts create mode 100644 tests/e2e/hardware-acceleration.spec.ts create mode 100644 tests/e2e/markdown-file-preview.spec.ts create mode 100644 tests/e2e/renderer-performance.spec.ts rename tests/unit/{chat-page-execution-graph.test.tsx => chat-acp-inline-timeline.test.tsx} (94%) delete mode 100644 tests/unit/chat-assistant-media-display.test.ts delete mode 100644 tests/unit/chat-event-dedupe.test.ts delete mode 100644 tests/unit/chat-helpers-enrichment.test.ts delete mode 100644 tests/unit/chat-helpers-extract-paths.test.ts delete mode 100644 tests/unit/chat-helpers-lifecycle-predicates.test.ts delete mode 100644 tests/unit/chat-history-actions.test.ts delete mode 100644 tests/unit/chat-internal-message-filter.test.ts delete mode 100644 tests/unit/chat-internal-sentinel-display.test.ts delete mode 100644 tests/unit/chat-message.test.tsx delete mode 100644 tests/unit/chat-optimistic-match.test.ts delete mode 100644 tests/unit/chat-runtime-event-handlers.test.ts delete mode 100644 tests/unit/chat-session-actions.test.ts create mode 100644 tests/unit/chat-session-management.test.ts delete mode 100644 tests/unit/chat-store-history-retry.test.ts delete mode 100644 tests/unit/chat-target-routing.test.ts create mode 100644 tests/unit/chat-toolbar.test.tsx delete mode 100644 tests/unit/chat-user-message-display.test.ts create mode 100644 tests/unit/config-mutex.test.ts delete mode 100644 tests/unit/execution-graph-card-badge.test.tsx delete mode 100644 tests/unit/gateway-bisection-0d794cd.test.ts create mode 100644 tests/unit/gateway-config-delivery.test.ts delete mode 100644 tests/unit/gateway-manager-reload-policy-refresh.test.ts delete mode 100644 tests/unit/gateway-reload-policy.test.ts delete mode 100644 tests/unit/gateway-rpc-backpressure.test.ts delete mode 100644 tests/unit/gateway-rpc-test-utils.ts delete mode 100644 tests/unit/generated-files-panel.test.tsx delete mode 100644 tests/unit/history-rpc-params.test.ts delete mode 100644 tests/unit/history-startup-retry.test.ts delete mode 100644 tests/unit/history-transcript-fa3ccd85-fixture.test.ts delete mode 100644 tests/unit/history-transcript-merge.test.ts delete mode 100644 tests/unit/image-generation-status.test.ts create mode 100644 tests/unit/main-hardware-acceleration.test.ts create mode 100644 tests/unit/markdown-preview.test.tsx create mode 100644 tests/unit/preinstalled-skill.test.ts delete mode 100644 tests/unit/run-segment-indices.test.ts delete mode 100644 tests/unit/session-label-fetch.test.ts create mode 100644 tests/unit/skill-config.test.ts create mode 100644 tests/unit/streamdown-config.test.tsx delete mode 100644 tests/unit/task-visualization.test.ts diff --git a/AGENTS.md b/AGENTS.md index 36dc9c3c..c099c0f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,12 +21,15 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones: | Comms baseline refresh | `pnpm run comms:baseline` | | Comms regression compare | `pnpm run comms:compare` | | E2E tests (Playwright) | `pnpm run test:e2e` | +| Chat performance profiles | `pnpm run perf:chat` | +| Electron Main inspector | `pnpm run profile:main` | | Build frontend only | `pnpm run build:vite` | ### Non-obvious caveats - **pnpm version**: The exact pnpm version is pinned via `packageManager` in `package.json`. Use `corepack enable && corepack prepare` to activate the correct version before installing. - **Electron on headless Linux**: The dbus errors (`Failed to connect to the bus`) are expected and harmless in a headless/cloud environment. The app still runs fine with `$DISPLAY` set (e.g., `:1` via Xvfb/VNC). +- **Performance profiling**: `pnpm run perf:chat` writes synthetic Renderer/Main CPU profiles and versioned metrics under ignored Playwright `test-results/`. For live Renderer CDP use `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev`; for live Main inspection use `pnpm run profile:main` and port 9229. - **`pnpm run lint` race condition**: If `pnpm run uv:download` was recently run, ESLint may fail with `ENOENT: no such file or directory, scandir '/workspace/temp_uv_extract'` because the temp directory was created and removed during download. Simply re-run lint after the download script finishes. - **Build scripts warning**: `pnpm install` may warn about ignored build scripts for `@discordjs/opus` and `koffi`. These are optional messaging-channel dependencies and the warnings are safe to ignore. - **`pnpm run init`**: This is a convenience script that runs `pnpm install` followed by `pnpm run uv:download`. Either run `pnpm run init` or run the two steps separately. diff --git a/README.ja-JP.md b/README.ja-JP.md index 539b025f..8c236128 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -101,13 +101,13 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。 ### 💬 インテリジェントチャットインターフェース -モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、エージェント応答の Markdown レンダリング(GitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]` を含む)をサポートし、ユーザー入力は常にプレーンテキストとして表示します。さらに、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。 +モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴に加え、シンタックスハイライト付きのフェンスコード、CJK 対応の解析、GitHub 風テーブル、KaTeX による LaTeX 数式(`$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]`)を含む、エージェント応答のストリーミング Markdown レンダリングをサポートします。ユーザー入力は常にプレーンテキストとして表示します。さらに、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。フェンスコードはソースの改行を保持し、長い行はソフトラップされ、ストリーミング完了後にローカライズされたコピー操作を利用できます。 コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。 `@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。 -セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。 +セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われ、失敗した場合は会話と確認ダイアログが保持されるため再試行できます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。 各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。 -Chat の右パネルにあるワークスペースとプレビューの各タブでは、`.docx` と `.pptx` ファイルを読み取り専用でプレビューできます。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。 +Chat の右パネルにあるワークスペースとプレビューの各タブでは、Markdown、`.docx`、`.pptx` ファイルを読み取り専用でプレビューできます。Markdown ファイルのプレビューでは、同じシンタックスハイライト、ソフトラップ、コピー操作付きのフェンスコード、CJK 対応の解析、KaTeX 数式を静的レンダリングモードでサポートします。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。 ### ローカル HTML プレビュー Chat の右パネルにはワークスペース、プレビュー、変更だけがあり、汎用ウェブブラウザ、ホーム画面、アドレスバーはありません。許可済みのローカル `.html` / `.htm` 添付ファイル、ファイルアクティビティ、ワークスペースファイルは既定でプレビューに開きます。ファイル操作では ClawX 内蔵プレビューまたはシステムアプリを選択でき、プレビューのヘッダーから現在の HTML ファイルをシステムブラウザで開くこともできます。 @@ -223,6 +223,8 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します: +OpenClaw の設定配信も Electron Main が一元管理します。Gateway の実行中は `config.get` の正規スナップショットを基準にし、変更を `config.set` でコミットします。Gateway が停止中または起動中の場合は、同じコーディネーターが解決済みの JSON5 設定ファイルだけを更新し、Gateway を起動しません。そのため、通常の Provider、Agent、Channel、バインディング、Skill、モデル変更では Gateway プロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更、ユーザーによる明示的な操作、ヘルスチェックやクラッシュ復旧に限定されます。認証プロファイルを SQLite に書き込んだ後は OpenClaw の `secrets.reload` を呼び出し、実行中の Agent がプロセス再起動なしで新しい認証情報を読み取れるようにします。 + Chat は Electron Main が所有する ACP stdio bridge を使用します。Renderer は型付き host event を受け取り、メモリ上の ACP timeline を描画します。Gateway は providers、models、skills、workspace、settings、diagnostics、media configuration などの非 Chat 機能を引き続き担当します。 別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。 @@ -380,6 +382,8 @@ pnpm typecheck # TypeScriptの型チェック pnpm test # ユニットテストを実行 pnpm run test:e2e # Electron E2E スモークテストを実行 pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行 +pnpm run perf:chat # 合成 Chat の Renderer/Main CPU プロファイルを取得 +pnpm run profile:main # ビルド済みアプリを Main inspector の 9229 番ポートで起動 pnpm run comms:replay # 通信リプレイ指標を算出 pnpm run comms:baseline # 通信ベースラインを更新 pnpm run comms:compare # リプレイ指標をベースライン閾値と比較 @@ -395,6 +399,14 @@ pnpm package:linux # Linux向けにパッケージ化 ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。 +### Electron パフォーマンス診断 + +`pnpm run perf:chat` は隔離された合成 ACP 負荷を実行し、ストリーミング応答と、リッチな静的 Markdown 会話でのサイドバーおよびスクロール操作を測定します。Playwright の `test-results/` には、バージョン付きメトリクスと Renderer/Main CPU プロファイルが出力されます。Renderer プロファイルは本番の store/render 経路とフレームペーシングを対象とします。ストリーミング Main プロファイルは Main から Renderer への IPC fanout を測定し、操作時の Main プロファイルは Renderer 操作中に Main がアイドルのままかを確認します。どちらも上流の OpenClaw/ACP サブプロセスや GPU プロセスの経路は含みません。CPU プロファイルは Chrome DevTools で開けます。アーティファクトには生成されたテスト文字列だけが含まれ、製品テレメトリーには送信されません。測定値はハードウェアに依存するため、共通の絶対閾値ではなく同じマシン上の複数回の結果を比較してください。 + +実際の Renderer を記録する場合は `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` で開発環境を起動し、Playwright または Chrome DevTools を `localhost:9223` に接続します。Electron Main を記録する場合は `pnpm run profile:main` を実行し、`chrome://inspect` で `localhost:9229` を設定して Electron Main target を選択します。WebSocket trace 自体を測定する場合を除き、`CLAWX_GATEWAY_WS_TRACE` は設定しないでください。 + +ClawX は Chromium のハードウェアアクセラレーションを既定で有効なままにし、長い文書、スクロール、レイアウトアニメーションで GPU コンポジットとラスタライズを利用します。グラフィックスドライバーに問題があるマシンでは、Chromium 標準の `--disable-gpu` コマンドラインスイッチをトラブルシューティング用のフォールバックとして利用できます。 + ### 通信回帰チェック PR が通信経路(Gateway イベント、ACP Chat bridge の送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。 diff --git a/README.md b/README.md index 0930ee45..724ad33e 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,13 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting. ### 💬 Intelligent Chat Interface -Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, assistant replies rendered with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`) while user input remains literal text, and direct `@agent` routing in the main composer for multi-agent setups. +Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, assistant replies rendered as streaming Markdown with syntax-highlighted fenced code, CJK-aware parsing, GitHub-flavored tables, and KaTeX-powered LaTeX math (`$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`) while user input remains literal text, and direct `@agent` routing in the main composer for multi-agent setups. Fenced code preserves source line breaks, soft-wraps long lines, and provides a localized copy action after streaming completes. Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`. When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings. -The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name. +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 Workspace and Preview tabs in Chat's right panel provide read-only previews for `.docx` and `.pptx` files. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline. +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. ### Local HTML Preview The Chat right panel has Workspace, Preview, and Changes tabs; it no longer includes a general Web Browser, Home page, or address bar. Authorized local `.html` and `.htm` attachments, file activities, and Workspace files open in Preview by default. Their file actions let you choose the built-in Preview or a system application, and the Preview header can open the current HTML file in the system browser. @@ -226,6 +226,8 @@ Notes: ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle: +Electron Main also owns OpenClaw configuration delivery. While the Gateway is running, ClawX reads the authoritative `config.get` snapshot and commits changes with `config.set`; while it is stopped or starting, the same coordinator updates the resolved JSON5 config file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts remain for process-launch environment changes such as proxy settings, explicit user actions, and health or crash recovery. Auth-profile SQLite updates use OpenClaw's `secrets.reload` RPC so running agents see new credentials without a process restart. + Chat uses an ACP stdio bridge owned by Electron Main. Renderer receives typed host events and renders an in-memory ACP timeline. Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration. An unfinished ACP response keeps streaming when you open another conversation or page. Returning before it finishes restores the latest in-memory timeline and continues the live response; once it finishes, normal ACP history replay remains the source of truth. @@ -383,6 +385,8 @@ pnpm typecheck # TypeScript validation pnpm test # Run unit tests pnpm run test:e2e # Run Electron E2E smoke tests with Playwright pnpm run test:e2e:headed # Run Electron E2E tests with a visible window +pnpm run perf:chat # Capture synthetic Chat Renderer/Main CPU profiles +pnpm run profile:main # Launch the built app with Main inspector on port 9229 pnpm run comms:replay # Compute communication replay metrics pnpm run comms:baseline # Refresh communication baseline snapshot pnpm run comms:compare # Compare replay metrics against baseline thresholds @@ -398,6 +402,14 @@ pnpm package:linux # Package for Linux On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`. +### Electron Performance Diagnostics + +`pnpm run perf:chat` runs isolated synthetic ACP workloads for streaming and for rich static Markdown sidebar/scroll interaction. It writes versioned metrics plus Renderer and Main CPU profiles under the Playwright `test-results/` directory. The Renderer profiles cover the production store/render path and frame pacing. The streaming Main profile measures Main-to-Renderer IPC fanout; the interaction Main profile shows whether Main remains idle while Renderer interactions run. Neither includes the upstream OpenClaw/ACP subprocess or GPU-process paths. Open a CPU profile in Chrome DevTools; the artifacts contain generated fixture text only and are not product telemetry. Results are hardware-dependent, so compare repeated runs on the same machine instead of applying one cross-platform absolute threshold. + +For a live Renderer recording, start development with `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` and attach Playwright or Chrome DevTools to `localhost:9223`. For a live Electron Main recording, run `pnpm run profile:main`, open `chrome://inspect`, configure `localhost:9229`, and select the Electron Main target. Leave `CLAWX_GATEWAY_WS_TRACE` unset unless WebSocket tracing itself is being measured. + +ClawX leaves Chromium hardware acceleration enabled by default so long documents, scrolling, and layout animations can use GPU compositing and rasterization. Chromium still honors the native `--disable-gpu` command-line switch as a troubleshooting fallback for a machine with a broken graphics driver. + ### Communication Regression Checks When a PR changes communication paths (gateway events, ACP Chat bridge send/receive flow, channel delivery, or transport fallback), run: diff --git a/README.zh-CN.md b/README.zh-CN.md index 7cd9d6e8..2a79311a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -102,13 +102,13 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。 ### 💬 智能聊天界面 -通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、以 Markdown 渲染智能体回复(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`),用户输入则始终按原始文本显示;同时支持在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。 +通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录,并以流式 Markdown 渲染智能体回复,支持带语法高亮的围栏代码块、面向中日韩文本的解析、GitHub 风格表格,以及由 KaTeX 渲染的 LaTeX 数学公式(`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`);用户输入则始终按原始文本显示。同时支持在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。围栏代码会保留源码换行、自动软换行,并在流式输出结束后提供本地化的复制操作。 从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`。 当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。 -会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。 +会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。只有永久删除成功后,会话行才会移除且页面才会跳转;删除失败时会保留会话与确认框,方便重试。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。 每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。 -Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 `.docx` 和 `.pptx` 文件。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。 +Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 Markdown、`.docx` 和 `.pptx` 文件。Markdown 文件预览以静态渲染模式提供相同的围栏代码语法高亮、软换行与复制操作、面向中日韩文本的解析和 KaTeX 数学公式支持。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。 ### 本地 HTML 预览 Chat 右侧面板只包含工作空间、预览和变更,不再提供通用网页浏览器、主页或地址栏。已授权的本地 `.html` 和 `.htm` 附件、文件活动及工作空间文件默认在预览中打开。文件操作可以选择 ClawX 内置预览或系统应用,预览标题栏也可将当前 HTML 文件交给系统浏览器打开。 @@ -227,6 +227,8 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问 ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理: +OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化、用户显式操作以及健康检查或崩溃恢复。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。 + Chat 使用由 Electron Main 持有的 ACP stdio bridge。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。 打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。 @@ -384,6 +386,8 @@ pnpm typecheck # TypeScript 类型检查 pnpm test # 运行单元测试 pnpm run test:e2e # 运行 Electron E2E 冒烟测试 pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试 +pnpm run perf:chat # 采集合成 Chat 场景的 Renderer/Main CPU Profile +pnpm run profile:main # 启动构建产物并在 9229 端口调试 Main pnpm run comms:replay # 计算通信回放指标 pnpm run comms:baseline # 刷新通信基线快照 pnpm run comms:compare # 将回放指标与基线阈值对比 @@ -399,6 +403,14 @@ pnpm package:linux # 为 Linux 打包 在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。 +### Electron 性能诊断 + +`pnpm run perf:chat` 会运行隔离的合成 ACP 负载,分别覆盖流式响应,以及富 Markdown 静态会话中的侧栏和滚动交互,并在 Playwright 的 `test-results/` 目录输出版本化指标与 Renderer/Main CPU Profile。Renderer Profile 覆盖生产 store/render 路径和帧节奏;流式 Main Profile 测量 Main 到 Renderer 的 IPC fanout,交互 Main Profile 用于确认 Renderer 交互期间 Main 是否保持空闲。两者都不包含上游 OpenClaw/ACP 子进程或 GPU 进程路径。CPU Profile 可直接用 Chrome DevTools 打开;其中只包含生成的测试文本,不会上报为产品遥测。性能数据依赖硬件,应在同一机器上多次运行后对比,不应使用统一的跨平台绝对阈值。 + +录制真实 Renderer 时,使用 `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` 启动开发环境,再让 Playwright 或 Chrome DevTools 连接 `localhost:9223`。录制真实 Electron Main 时,运行 `pnpm run profile:main`,在 `chrome://inspect` 中配置 `localhost:9229` 并选择 Electron Main target。除非正在测量 WebSocket trace 本身,否则不要设置 `CLAWX_GATEWAY_WS_TRACE`。 + +ClawX 默认保留 Chromium 硬件加速,使长文档、滚动和布局动画能够使用 GPU 合成与光栅化。若某台机器的显卡驱动存在问题,仍可使用 Chromium 原生的 `--disable-gpu` 命令行参数作为排障回退。 + ### 通信回归检查 当 PR 涉及通信链路(Gateway 事件、ACP Chat bridge 收发流程、Channel 投递、传输回退)时,建议执行: diff --git a/electron/gateway/client.ts b/electron/gateway/client.ts deleted file mode 100644 index 7efb144f..00000000 --- a/electron/gateway/client.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Gateway WebSocket Client - * Provides a typed interface for Gateway RPC calls - */ -import { GatewayManager, GatewayStatus } from './manager'; - -/** - * Channel types supported by OpenClaw - */ -export type ChannelType = 'whatsapp' | 'dingtalk' | 'telegram' | 'discord' | 'wechat'; - -/** - * Channel status - */ -export interface Channel { - id: string; - type: ChannelType; - name: string; - status: 'connected' | 'disconnected' | 'connecting' | 'error'; - lastActivity?: string; - error?: string; - config?: Record; -} - -/** - * Skill definition - */ -export interface Skill { - id: string; - name: string; - description: string; - enabled: boolean; - category?: string; - icon?: string; - configurable?: boolean; - version?: string; - author?: string; -} - -/** - * Skill bundle definition - */ -export interface SkillBundle { - id: string; - name: string; - description: string; - skills: string[]; - icon?: string; - recommended?: boolean; -} - -/** - * Chat message - */ -export interface ChatMessage { - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp: string; - channel?: string; - toolCalls?: ToolCall[]; - metadata?: Record; -} - -/** - * Tool call in a message - */ -export interface ToolCall { - id: string; - name: string; - arguments: Record; - result?: unknown; - status: 'pending' | 'running' | 'completed' | 'error'; - duration?: number; -} - -/** - * Cron task definition - */ -export interface CronTask { - id: string; - name: string; - schedule: string; - command: string; - enabled: boolean; - lastRun?: string; - nextRun?: string; - status: 'idle' | 'running' | 'error'; - error?: string; -} - -/** - * Provider configuration - */ -export interface ProviderConfig { - id: string; - name: string; - type: 'openai' | 'anthropic' | 'ollama' | 'custom'; - apiKey?: string; - baseUrl?: string; - model?: string; - enabled: boolean; -} - -/** - * Gateway Client - * Typed wrapper around GatewayManager for making RPC calls - */ -export class GatewayClient { - constructor(private manager: GatewayManager) { } - - /** - * Get current gateway status - */ - getStatus(): GatewayStatus { - return this.manager.getStatus(); - } - - /** - * Check if gateway is connected - */ - isConnected(): boolean { - return this.manager.isConnected(); - } - - // ==================== Channel Methods ==================== - - /** - * List all channels - */ - async listChannels(): Promise { - return this.manager.rpc('channels.list'); - } - - /** - * Get channel by ID - */ - async getChannel(channelId: string): Promise { - return this.manager.rpc('channels.get', { channelId }); - } - - /** - * Connect a channel - */ - async connectChannel(channelId: string): Promise { - return this.manager.rpc('channels.connect', { channelId }); - } - - /** - * Disconnect a channel - */ - async disconnectChannel(channelId: string): Promise { - return this.manager.rpc('channels.disconnect', { channelId }); - } - - /** - * Get QR code for channel connection (e.g., WhatsApp) - */ - async getChannelQRCode(channelType: ChannelType): Promise { - return this.manager.rpc('channels.getQRCode', { channelType }); - } - - // ==================== Skill Methods ==================== - - /** - * List all skills - */ - async listSkills(): Promise { - return this.manager.rpc('skills.list'); - } - - /** - * Enable a skill - */ - async enableSkill(skillId: string): Promise { - return this.manager.rpc('skills.enable', { skillId }); - } - - /** - * Disable a skill - */ - async disableSkill(skillId: string): Promise { - return this.manager.rpc('skills.disable', { skillId }); - } - - /** - * Get skill configuration - */ - async getSkillConfig(skillId: string): Promise> { - return this.manager.rpc>('skills.getConfig', { skillId }); - } - - /** - * Update skill configuration - */ - async updateSkillConfig(skillId: string, config: Record): Promise { - return this.manager.rpc('skills.updateConfig', { skillId, config }); - } - - // ==================== Chat Methods ==================== - - /** - * Send a chat message - */ - async sendMessage(content: string, channelId?: string): Promise { - return this.manager.rpc('chat.send', { content, channelId }); - } - - /** - * Get chat history - */ - async getChatHistory(limit = 50, offset = 0): Promise { - return this.manager.rpc('chat.history', { limit, offset }); - } - - /** - * Clear chat history - */ - async clearChatHistory(): Promise { - return this.manager.rpc('chat.clear'); - } - - // ==================== Cron Methods ==================== - - /** - * List all cron tasks - */ - async listCronTasks(): Promise { - return this.manager.rpc('cron.list'); - } - - /** - * Create a new cron task - */ - async createCronTask(task: Omit): Promise { - return this.manager.rpc('cron.create', task); - } - - /** - * Update a cron task - */ - async updateCronTask(taskId: string, updates: Partial): Promise { - return this.manager.rpc('cron.update', { taskId, ...updates }); - } - - /** - * Delete a cron task - */ - async deleteCronTask(taskId: string): Promise { - return this.manager.rpc('cron.delete', { taskId }); - } - - /** - * Run a cron task immediately - */ - async runCronTask(taskId: string): Promise { - return this.manager.rpc('cron.run', { taskId }); - } - - // ==================== Provider Methods ==================== - - /** - * List configured AI providers - */ - async listProviders(): Promise { - return this.manager.rpc('providers.list'); - } - - /** - * Add or update a provider - */ - async setProvider(provider: ProviderConfig): Promise { - return this.manager.rpc('providers.set', provider); - } - - /** - * Remove a provider - */ - async removeProvider(providerId: string): Promise { - return this.manager.rpc('providers.remove', { providerId }); - } - - /** - * Test provider connection - */ - async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> { - return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId }); - } - - // ==================== System Methods ==================== - - /** - * Get Gateway health status - */ - async getHealth(): Promise<{ status: string; uptime: number; version?: string }> { - return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health'); - } - - /** - * Get Gateway configuration - */ - async getConfig(): Promise> { - return this.manager.rpc>('system.config'); - } - - /** - * Update Gateway configuration - */ - async updateConfig(config: Record): Promise { - return this.manager.rpc('system.updateConfig', config); - } - - /** - * Get Gateway version info - */ - async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> { - return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version'); - } - - /** - * Get available skill bundles - */ - async getSkillBundles(): Promise { - return this.manager.rpc('skills.bundles'); - } - - /** - * Install a skill bundle - */ - async installBundle(bundleId: string): Promise { - return this.manager.rpc('skills.installBundle', { bundleId }); - } -} diff --git a/electron/gateway/config-delivery.ts b/electron/gateway/config-delivery.ts new file mode 100644 index 00000000..2abb8942 --- /dev/null +++ b/electron/gateway/config-delivery.ts @@ -0,0 +1,289 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import JSON5 from 'json5'; +import type { GatewayManager } from './manager'; +import { withConfigLock } from '../utils/config-mutex'; +import { resolveOpenClawConfigPath } from '../utils/paths'; + +export type OpenClawConfig = Record; +/** Mutators may be replayed after a compare-and-swap conflict and must not perform external writes. */ +export type OpenClawConfigMutator = ( + config: OpenClawConfig, +) => void | Promise; + +type ConfigDeliveryGatewayManager = Pick; + +interface ConfigSnapshot { + config?: unknown; + raw?: unknown; + hash?: unknown; +} + +interface ActiveMutationContext { + config: OpenClawConfig; + active: boolean; + sourceExists: boolean; +} + +export interface OpenClawConfigSnapshot { + config: OpenClawConfig; + exists: boolean; +} + +interface FileConfigSnapshot { + config: OpenClawConfig; + raw: string | undefined; +} + +let gatewayManager: ConfigDeliveryGatewayManager | undefined; +let transactionTail: Promise = Promise.resolve(); +const activeMutation = new AsyncLocalStorage(); + +function parseConfig(raw: string): OpenClawConfig { + const parsed = JSON5.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('OpenClaw config must be an object'); + } + return parsed as OpenClawConfig; +} + +function serializeConfig(config: OpenClawConfig): string { + return `${JSON.stringify(config, null, 2)}\n`; +} + +function parseRunningConfigSnapshot(snapshot: ConfigSnapshot | undefined): OpenClawConfig { + if (snapshot?.config && typeof snapshot.config === 'object' && !Array.isArray(snapshot.config)) { + return structuredClone(snapshot.config) as OpenClawConfig; + } + const raw = typeof snapshot?.raw === 'string' ? snapshot.raw : ''; + if (!raw.trim()) { + throw new Error('Gateway config.get returned an incomplete config snapshot'); + } + return parseConfig(raw); +} + +function isBaseHashConflict(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /config changed since last load; re-run config\.get and retry/i.test(message); +} + +async function mutateRunningConfig( + manager: ConfigDeliveryGatewayManager, + mutator: OpenClawConfigMutator, +): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + const snapshot = await manager.rpc('config.get', {}); + const hash = typeof snapshot?.hash === 'string' ? snapshot.hash.trim() : ''; + if (!hash) { + throw new Error('Gateway config.get returned an incomplete config snapshot'); + } + + const config = parseRunningConfigSnapshot(snapshot); + if (!await applyMutator(config, mutator, true)) return false; + + try { + await manager.rpc('config.set', { + raw: serializeConfig(config), + baseHash: hash, + }); + return true; + } catch (error) { + if (attempt === 0 && isBaseHashConflict(error)) continue; + throw error; + } + } + + return false; +} + +async function applyMutator( + config: OpenClawConfig, + mutator: OpenClawConfigMutator, + sourceExists: boolean, +): Promise { + const baseline = structuredClone(config); + const context: ActiveMutationContext = { config, active: true, sourceExists }; + try { + await activeMutation.run(context, async () => await mutator(config)); + } finally { + context.active = false; + } + return !isDeepStrictEqual(config, baseline); +} + +async function applyNestedMutator( + context: ActiveMutationContext, + mutator: OpenClawConfigMutator, +): Promise { + const baseline = structuredClone(context.config); + await mutator(context.config); + return !isDeepStrictEqual(context.config, baseline); +} + +async function readFileConfig(configPath: string): Promise { + try { + const raw = await readFile(configPath, 'utf8'); + return { config: parseConfig(raw), raw }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { config: {}, raw: undefined }; + } + throw error; + } +} + +async function readFileRaw(configPath: string): Promise { + try { + return await readFile(configPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +async function removeTemporaryFile(temporaryPath: string): Promise { + try { + await unlink(temporaryPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} + +async function mutateFileConfig( + manager: ConfigDeliveryGatewayManager | undefined, + mutator: OpenClawConfigMutator, +): Promise { + return await withConfigLock(async () => { + const configPath = resolveOpenClawConfigPath(); + for (let attempt = 0; attempt < 2; attempt += 1) { + const snapshot = await readFileConfig(configPath); + const changed = await applyMutator(snapshot.config, mutator, snapshot.raw !== undefined); + + if (manager?.getStatus().state === 'running') { + return await mutateRunningConfig(manager, mutator); + } + if (!changed) return false; + + await mkdir(dirname(configPath), { recursive: true }); + const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, serializeConfig(snapshot.config), { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + + try { + if (manager?.getStatus().state === 'running') { + return await mutateRunningConfig(manager, mutator); + } + + const currentRaw = await readFileRaw(configPath); + if (manager?.getStatus().state === 'running') { + return await mutateRunningConfig(manager, mutator); + } + if (currentRaw !== snapshot.raw) { + if (attempt === 0) continue; + throw new Error('OpenClaw config changed during file mutation; retry the mutation'); + } + + await rename(temporaryPath, configPath); + return true; + } finally { + await removeTemporaryFile(temporaryPath); + } + } + + return false; + }); +} + +async function runMutation(mutator: OpenClawConfigMutator): Promise { + const manager = gatewayManager; + if (manager?.getStatus().state === 'running') { + return await mutateRunningConfig(manager, mutator); + } + return await mutateFileConfig(manager, mutator); +} + +async function runRead(): Promise { + const manager = gatewayManager; + if (manager?.getStatus().state === 'running') { + const snapshot = await manager.rpc('config.get', {}); + return { config: parseRunningConfigSnapshot(snapshot), exists: true }; + } + + const snapshot = await readFileConfig(resolveOpenClawConfigPath()); + return { config: snapshot.config, exists: snapshot.raw !== undefined }; +} + +async function runSecretsReload(): Promise { + const manager = gatewayManager; + if (manager?.getStatus().state !== 'running') return false; + await manager.rpc('secrets.reload', {}); + return true; +} + +export function registerOpenClawConfigCoordinator( + manager: ConfigDeliveryGatewayManager, +): void { + gatewayManager = manager; +} + +export function mutateOpenClawConfig( + mutator: OpenClawConfigMutator, +): Promise { + const context = activeMutation.getStore(); + if (context?.active) { + return applyNestedMutator(context, mutator); + } + + const transaction = transactionTail.then( + () => runMutation(mutator), + () => runMutation(mutator), + ); + transactionTail = transaction.then( + () => undefined, + () => undefined, + ); + return transaction; +} + +export function readOpenClawConfigSnapshot(): Promise { + const context = activeMutation.getStore(); + if (context?.active) { + return Promise.resolve({ + config: structuredClone(context.config), + exists: context.sourceExists, + }); + } + + const transaction = transactionTail.then( + () => runRead(), + () => runRead(), + ); + transactionTail = transaction.then( + () => undefined, + () => undefined, + ); + return transaction; +} + +export function reloadOpenClawSecretsIfRunning(): Promise { + const transaction = transactionTail.then( + () => runSecretsReload(), + () => runSecretsReload(), + ); + transactionTail = transaction.then( + () => undefined, + () => undefined, + ); + return transaction; +} + +export function resetOpenClawConfigCoordinatorForTests(): void { + gatewayManager = undefined; + transactionTail = Promise.resolve(); +} diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index d52f42fb..6da184b8 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -33,7 +33,7 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { logger } from '../utils/logger'; import { prependPathEntry } from '../utils/env-path'; -import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install'; +import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install'; import { safeRmSync } from '../utils/safe-fs'; import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants'; import { ensureOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot'; @@ -196,7 +196,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean safeRmSync(fsPath(targetDir)); cpSyncSafe(bundledDir, targetDir); fixupPluginManifest(targetDir); - syncTrustedOfficialPluginInstallRecord(dirName, targetDir); } catch (err) { logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err); succeeded = false; @@ -205,7 +204,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean // Same version already installed — still patch manifest ID in case it was // never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin). fixupPluginManifest(targetDir); - syncTrustedOfficialPluginInstallRecord(dirName, targetDir); } continue; } @@ -219,7 +217,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean // Skip only if installed AND same version — but still patch manifest ID. if (isInstalled && installedVersion && sourceVersion === installedVersion) { fixupPluginManifest(targetDir); - syncTrustedOfficialPluginInstallRecord(dirName, targetDir); continue; } @@ -229,7 +226,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true }); copyPluginFromNodeModules(npmPkgPath, targetDir, npmName); fixupPluginManifest(targetDir); - syncTrustedOfficialPluginInstallRecord(dirName, targetDir); } catch (err) { logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err); succeeded = false; @@ -268,7 +264,7 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea return succeeded; } -function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): void { +async function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): Promise { const configuredSet = new Set(configuredChannels); for (const [channelType, { dirName }] of Object.entries(CHANNEL_PLUGIN_MAP)) { if (configuredSet.has(channelType)) continue; @@ -276,7 +272,7 @@ function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: stri // 2026.6.10 → 2026.7.1 migration). OpenClaw validates tracked records even // when the channel is no longer configured, so reconcile this on every // launch rather than hiding it behind the directory-maintenance cache. - removeTrustedOfficialPluginInstallRecord(dirName); + await removeTrustedOfficialPluginInstallRecord(dirName); } } @@ -540,9 +536,9 @@ export async function syncGatewayConfigBeforeLaunch( // Always refresh trusted install metadata through ClawX — this must not // be skipped when plugin-maintenance is cache-hit, otherwise official // external plugins like WhatsApp fail openKeyedStore at runtime. - measureSync(timingsMs, 'trustedPluginInstallSyncMs', () => { - cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels); - repairTrustedOfficialPluginInstallRecords(); + await measureAsync(timingsMs, 'trustedPluginInstallSyncMs', async () => { + await cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels); + await repairTrustedOfficialPluginInstallRecords(); }); } catch (err) { logger.warn('Failed to auto-upgrade plugins:', err); diff --git a/electron/gateway/event-dispatch.ts b/electron/gateway/event-dispatch.ts index f652543a..a6318e51 100644 --- a/electron/gateway/event-dispatch.ts +++ b/electron/gateway/event-dispatch.ts @@ -27,7 +27,6 @@ export function dispatchProtocolEvent( if (normalized) { emitter.emit('chat:runtime-event', normalized); } - emitter.emit('notification', { method: event, params: payload }); break; } case 'channel.status': @@ -53,14 +52,17 @@ export function dispatchJsonRpcNotification( emitter: GatewayEventEmitter, notification: JsonRpcNotification, ): void { - emitter.emit('notification', notification); if (notification.method === 'agent') { const normalized = normalizeGatewayChatRuntimeEvent(notification.params); if (normalized) { emitter.emit('chat:runtime-event', normalized); } + } else { + emitter.emit('notification', notification); } switch (notification.method) { + case 'agent': + break; case GatewayEventType.CHANNEL_STATUS_CHANGED: emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent); break; diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index bb2d14d7..6029a824 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -50,11 +50,6 @@ import { GatewayLifecycleController, LifecycleSupersededError } from './lifecycl import { launchGatewayProcess } from './process-launcher'; import { GatewayRestartController } from './restart-controller'; import { GatewayRestartGovernor } from './restart-governor'; -import { - DEFAULT_GATEWAY_RELOAD_POLICY, - loadGatewayReloadPolicy, - type GatewayReloadPolicy, -} from './reload-policy'; import { classifyGatewayStderrMessage, GATEWAY_STARTUP_SLOW_STAGE_MS, @@ -195,16 +190,11 @@ export class GatewayManager extends EventEmitter { private readonly lifecycleController = new GatewayLifecycleController(); private readonly restartController = new GatewayRestartController(); private readonly restartGovernor = new GatewayRestartGovernor(); - private reloadDebounceTimer: NodeJS.Timeout | null = null; private initialReadyHeartbeatRecoveryTimer: NodeJS.Timeout | null = null; private upgradeSnapshotCleanupAttempted = false; - private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY }; - private reloadPolicyLoadedAt = 0; - private reloadPolicyRefreshPromise: Promise | null = null; private externalShutdownSupported: boolean | null = null; private reconnectAttemptsTotal = 0; private reconnectSuccessTotal = 0; - private static readonly RELOAD_POLICY_REFRESH_MS = 15_000; private static readonly HEARTBEAT_INTERVAL_MS = 60_000; private static readonly HEARTBEAT_TIMEOUT_MS = 30_000; private static readonly HEARTBEAT_MAX_MISSES = 4; @@ -343,8 +333,6 @@ export class GatewayManager extends EventEmitter { logger.info(`Gateway start requested (port=${this.status.port})`); this.lastSpawnSummary = null; this.shouldReconnect = true; - await this.refreshReloadPolicy(true); - // Lazily load device identity (async file I/O + key generation). // Must happen before connect() which uses the identity for the handshake. await this.initDeviceIdentity(); @@ -687,138 +675,6 @@ export class GatewayManager extends EventEmitter { }); } - /** - * Ask the Gateway process to reload config in-place when possible. - * Falls back to restart on unsupported platforms or signaling failures. - */ - async reload(): Promise { - await this.refreshReloadPolicy(); - - if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') { - logger.info( - `[gateway-refresh] mode=reload result=policy_forced_restart policy=${this.reloadPolicy.mode}`, - ); - await this.restart(); - return; - } - - if (this.restartController.isRestartDeferred({ - state: this.status.state, - startLock: this.startLock, - })) { - this.restartController.markDeferredRestart('reload', { - state: this.status.state, - startLock: this.startLock, - }); - return; - } - - const pidBefore = this.process?.pid; - logger.info(`[gateway-refresh] mode=reload requested pid=${pidBefore ?? 'n/a'} state=${this.status.state}`); - - if (!this.process?.pid || this.status.state !== 'running') { - logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=not_running'); - logger.warn('Gateway reload requested while not running; falling back to restart'); - await this.restart(); - return; - } - - const connectedForMs = this.status.connectedAt - ? Date.now() - this.status.connectedAt - : Number.POSITIVE_INFINITY; - - // Avoid signaling a process that just came up; it will already read latest config. - if (connectedForMs < 8000) { - logger.info( - `[gateway-refresh] mode=reload result=skipped_recent_connect connectedForMs=${connectedForMs} pid=${this.process.pid}`, - ); - logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`); - return; - } - - if (process.platform === 'win32') { - // Windows does not support SIGUSR1 for in-process reload. - // Fall back to a full restart. The connectedForMs < 8000 guard above - // already skips unnecessary restarts for recently-started processes. - logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=windows'); - await this.restart(); - return; - } - - try { - process.kill(this.process.pid, 'SIGUSR1'); - logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`); - // Some gateway builds do not handle SIGUSR1 as an in-process reload. - // If process state doesn't recover quickly, fall back to restart. - await new Promise((resolve) => setTimeout(resolve, 1500)); - if (this.status.state !== 'running' || !this.process?.pid) { - logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=post_signal_unhealthy'); - logger.warn('Gateway did not stay running after reload signal, falling back to restart'); - await this.restart(); - } else { - const pidAfter = this.process.pid; - logger.info( - `[gateway-refresh] mode=reload result=applied_in_place pidBefore=${pidBefore} pidAfter=${pidAfter}`, - ); - } - } catch (error) { - logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=signal_error'); - logger.warn('Gateway reload signal failed, falling back to restart:', error); - await this.restart(); - } - } - - /** - * Debounced reload — coalesces multiple rapid config-change events into one - * in-process reload when possible. - */ - debouncedReload(delayMs?: number): void { - void this.refreshReloadPolicy(); - const effectiveDelay = delayMs ?? this.reloadPolicy.debounceMs; - if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') { - logger.debug( - `Gateway reload policy=${this.reloadPolicy.mode}; routing debouncedReload to debouncedRestart (${effectiveDelay}ms)`, - ); - this.debouncedRestart(effectiveDelay); - return; - } - - if (this.reloadDebounceTimer) { - clearTimeout(this.reloadDebounceTimer); - } - logger.debug(`Gateway reload debounced (will fire in ${effectiveDelay}ms)`); - this.reloadDebounceTimer = setTimeout(() => { - this.reloadDebounceTimer = null; - void this.reload().catch((err) => { - logger.warn('Debounced Gateway reload failed:', err); - }); - }, effectiveDelay); - } - - private async refreshReloadPolicy(force = false): Promise { - const now = Date.now(); - if (!force && now - this.reloadPolicyLoadedAt < GatewayManager.RELOAD_POLICY_REFRESH_MS) { - return; - } - - if (this.reloadPolicyRefreshPromise) { - await this.reloadPolicyRefreshPromise; - return; - } - - this.reloadPolicyRefreshPromise = (async () => { - const nextPolicy = await loadGatewayReloadPolicy(); - this.reloadPolicy = nextPolicy; - this.reloadPolicyLoadedAt = Date.now(); - })(); - - try { - await this.reloadPolicyRefreshPromise; - } finally { - this.reloadPolicyRefreshPromise = null; - } - } - /** * Clear all active timers */ @@ -829,10 +685,6 @@ export class GatewayManager extends EventEmitter { } this.connectionMonitor.clear(); this.restartController.clearDebounceTimer(); - if (this.reloadDebounceTimer) { - clearTimeout(this.reloadDebounceTimer); - this.reloadDebounceTimer = null; - } this.resetGatewayReadyFallback(); this.clearInitialReadyHeartbeatRecoveryTimer(); } diff --git a/electron/gateway/reload-policy.ts b/electron/gateway/reload-policy.ts deleted file mode 100644 index 67494d2b..00000000 --- a/electron/gateway/reload-policy.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -export type GatewayReloadMode = 'hybrid' | 'reload' | 'restart' | 'off'; - -export type GatewayReloadPolicy = { - mode: GatewayReloadMode; - debounceMs: number; -}; - -export const DEFAULT_GATEWAY_RELOAD_POLICY: GatewayReloadPolicy = { - mode: 'hybrid', - debounceMs: 1200, -}; - -const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); -const MAX_DEBOUNCE_MS = 60_000; - -function normalizeMode(value: unknown): GatewayReloadMode { - if (value === 'off' || value === 'reload' || value === 'restart' || value === 'hybrid') { - return value; - } - return DEFAULT_GATEWAY_RELOAD_POLICY.mode; -} - -function normalizeDebounceMs(value: unknown): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return DEFAULT_GATEWAY_RELOAD_POLICY.debounceMs; - } - const rounded = Math.round(value); - if (rounded < 0) return 0; - if (rounded > MAX_DEBOUNCE_MS) return MAX_DEBOUNCE_MS; - return rounded; -} - -export function parseGatewayReloadPolicy(config: unknown): GatewayReloadPolicy { - if (!config || typeof config !== 'object') { - return { ...DEFAULT_GATEWAY_RELOAD_POLICY }; - } - const root = config as Record; - const gateway = (root.gateway && typeof root.gateway === 'object' - ? root.gateway - : {}) as Record; - const reload = (gateway.reload && typeof gateway.reload === 'object' - ? gateway.reload - : {}) as Record; - - return { - mode: normalizeMode(reload.mode), - debounceMs: normalizeDebounceMs(reload.debounceMs), - }; -} - -export async function loadGatewayReloadPolicy(): Promise { - try { - const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8'); - return parseGatewayReloadPolicy(JSON.parse(raw)); - } catch { - return { ...DEFAULT_GATEWAY_RELOAD_POLICY }; - } -} - diff --git a/electron/gateway/rpc-backpressure.ts b/electron/gateway/rpc-backpressure.ts deleted file mode 100644 index fbf7aa2a..00000000 --- a/electron/gateway/rpc-backpressure.ts +++ /dev/null @@ -1,101 +0,0 @@ -type GatewayRpcRunner = (method: string, params?: unknown, timeoutMs?: number) => Promise; - -type QueuedRpc = { - run: () => Promise; -}; - -function stableStringify(value: unknown): string { - if (value === null || typeof value !== 'object') { - return JSON.stringify(value); - } - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - return `{${Object.keys(record).sort().map((key) => ( - `${JSON.stringify(key)}:${stableStringify(record[key])}` - )).join(',')}}`; -} - -export interface GatewayRpcBackpressureOptions { - maxConcurrentHistory?: number; -} - -/** - * Prevents renderer fan-out from forwarding an unbounded number of expensive - * chat.history RPCs to OpenClaw. The Gateway still owns the canonical response; - * this class only coalesces duplicate in-flight history calls and runs distinct - * history requests through a small FIFO queue. - */ -export class GatewayRpcBackpressure { - private readonly maxConcurrentHistory: number; - private readonly inFlightHistory = new Map>(); - private readonly queue: QueuedRpc[] = []; - private activeHistory = 0; - - constructor(options: GatewayRpcBackpressureOptions = {}) { - this.maxConcurrentHistory = Math.max(1, options.maxConcurrentHistory ?? 2); - } - - run( - method: string, - params: unknown, - timeoutMs: number | undefined, - runner: GatewayRpcRunner, - ): Promise { - if (method !== 'chat.history') { - return runner(method, params, timeoutMs); - } - - const key = `${method}:${stableStringify(params)}:${timeoutMs ?? 'default'}`; - const existing = this.inFlightHistory.get(key); - if (existing) return existing; - - const promise = this.enqueueHistory(() => runner(method, params, timeoutMs)) - .finally(() => { - if (this.inFlightHistory.get(key) === promise) { - this.inFlightHistory.delete(key); - } - }); - this.inFlightHistory.set(key, promise); - return promise; - } - - getDiagnostics(): { activeHistory: number; queuedHistory: number; inFlightHistory: number } { - return { - activeHistory: this.activeHistory, - queuedHistory: this.queue.length, - inFlightHistory: this.inFlightHistory.size, - }; - } - - private enqueueHistory(work: () => Promise): Promise { - return new Promise((resolve, reject) => { - const queued: QueuedRpc = { - run: async () => { - this.activeHistory += 1; - try { - resolve(await work()); - } catch (error) { - reject(error); - } finally { - this.activeHistory -= 1; - this.drain(); - } - }, - }; - this.queue.push(queued); - this.drain(); - }); - } - - private drain(): void { - while (this.activeHistory < this.maxConcurrentHistory) { - const next = this.queue.shift(); - if (!next) return; - void next.run(); - } - } -} diff --git a/electron/gateway/ws-trace.ts b/electron/gateway/ws-trace.ts index d30fe8de..a6a1f65c 100644 --- a/electron/gateway/ws-trace.ts +++ b/electron/gateway/ws-trace.ts @@ -9,6 +9,7 @@ const SECRET_KEYS = new Set([ 'accesstoken', 'refreshtoken', ]); +const CONFIG_WRITE_METHODS = new Set(['config.set', 'config.patch', 'config.apply']); export function isGatewayWsTraceEnabled(): boolean { return process.env.CLAWX_GATEWAY_WS_TRACE === '1'; @@ -22,9 +23,17 @@ export function redactGatewayFrameForTrace(value: unknown): unknown { return value; } + const record = value as Record; + const redactConfigRaw = typeof record.method === 'string' && CONFIG_WRITE_METHODS.has(record.method); const result: Record = {}; - for (const [key, item] of Object.entries(value as Record)) { + for (const [key, item] of Object.entries(record)) { const normalizedKey = key.toLowerCase(); + if (redactConfigRaw && key === 'params' && item && typeof item === 'object' && !Array.isArray(item)) { + const params = redactGatewayFrameForTrace(item) as Record; + if (Object.hasOwn(params, 'raw')) params.raw = '[redacted]'; + result[key] = params; + continue; + } result[key] = SECRET_KEYS.has(normalizedKey) ? '[redacted]' : redactGatewayFrameForTrace(item); diff --git a/electron/main/index.ts b/electron/main/index.ts index c120caa8..8ea265cc 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron'; import { join } from 'path'; import { GatewayManager } from '../gateway/manager'; +import { registerOpenClawConfigCoordinator } from '../gateway/config-delivery'; import { registerIpcHandlers } from './ipc-handlers'; import { HostApiRegistry } from './ipc/host-invoke'; import { createTray } from './tray'; @@ -67,22 +68,6 @@ if (isE2EMode && requestedUserDataDir) { app.setPath('userData', requestedUserDataDir); } -// Disable GPU hardware acceleration globally for maximum stability across -// all GPU configurations (no GPU, integrated, discrete). -// -// Rationale (following VS Code's philosophy): -// - Page/file loading is async data fetching — zero GPU dependency. -// - The original per-platform GPU branching was added to avoid CPU rendering -// competing with sync I/O on Windows, but all file I/O is now async -// (fs/promises), so that concern no longer applies. -// - Software rendering is deterministic across all hardware; GPU compositing -// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and -// driver versions, making it the #1 source of rendering bugs in Electron. -// -// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or -// set `"disable-hardware-acceleration": false` in the app config (future). -app.disableHardwareAcceleration(); - // On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file. // On Wayland this maps the running window to clawx.desktop (→ icon + app grouping); // on X11 it supplements the StartupWMClass matching. @@ -604,6 +589,7 @@ if (gotTheLock) { } gatewayManager = new GatewayManager(); + registerOpenClawConfigCoordinator(gatewayManager); clawHubService = new ClawHubService(); // Register builtin extensions and load manifest diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 4f4329ad..7d42c577 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -25,8 +25,6 @@ import { resolveAgentIdFromChannel } from '../utils/agent-config'; import { resolveAccountIdFromSessionHistory } from '../utils/session-util'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; -import { deviceOAuthManager } from '../utils/device-oauth'; -import { browserOAuthManager } from '../utils/browser-oauth'; import { applyProxySettings } from './proxy'; import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; @@ -42,7 +40,6 @@ import { } from '../services/providers/provider-runtime-sync'; import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; import { appUpdater } from './updater'; -import { GatewayRpcBackpressure } from '../gateway/rpc-backpressure'; import { HostApiRegistry, registerHostInvokeHandler } from './ipc/host-invoke'; import { createAppApi } from '../services/app-api'; import { createOpenClawApi } from '../services/openclaw-api'; @@ -78,8 +75,6 @@ import { } from './ipc/request-helpers'; import { createMenu } from './menu'; -const gatewayRpcBackpressure = new GatewayRpcBackpressure(); - /** * Register all IPC handlers */ @@ -167,7 +162,7 @@ function registerTypedHostHandlers( updates: createUpdatesApi(appUpdater), uv: createUvApi(), settings: createSettingsApi(gatewayManager), - gateway: createGatewayApi(gatewayManager, gatewayRpcBackpressure), + gateway: createGatewayApi(gatewayManager), logs: createLogsApi(), channels: createChannelsApi({ gatewayManager, mainWindow }), agents: createAgentsApi({ gatewayManager }), @@ -298,11 +293,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { } } - try { - await syncSavedProviderToRuntime(config, apiKey, gatewayManager); - } catch (err) { - console.warn('Failed to sync openclaw provider config:', err); - } + await syncSavedProviderToRuntime(config, apiKey, gatewayManager); data = { success: true }; } catch (error) { @@ -317,14 +308,10 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { try { const existing = await providerService.getLegacyProvider(providerId); - await providerService.deleteLegacyProvider(providerId); if (existing?.type) { - try { - await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); - } catch (err) { - console.warn('Failed to completely remove provider from OpenClaw:', err); - } + await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); } + await providerService.deleteLegacyProvider(providerId); data = { success: true }; } catch (error) { data = { success: false, error: String(error) }; @@ -345,11 +332,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; const ock = getOpenClawProviderKey(providerType, providerId); - try { - await saveProviderKeyToOpenClaw(ock, apiKey); - } catch (err) { - console.warn('Failed to save key to OpenClaw auth-profiles:', err); - } + await saveProviderKeyToOpenClaw(ock, apiKey); data = { success: true }; } catch (error) { data = { success: false, error: String(error) }; @@ -395,11 +378,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { } } - try { - await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); - } catch (err) { - console.warn('Failed to sync openclaw config after provider update:', err); - } + await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); data = { success: true }; } catch (error) { @@ -429,12 +408,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; const ock = getOpenClawProviderKey(providerType, providerId); - try { - if (ock) { - await removeProviderFromOpenClaw(ock); - } - } catch (err) { - console.warn('Failed to completely remove provider from OpenClaw:', err); + if (ock) { + await removeProviderFromOpenClaw(ock); } data = { success: true }; } catch (error) { @@ -451,11 +426,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { await providerService.setDefaultLegacyProvider(providerId); const provider = await providerService.getLegacyProvider(providerId); if (provider) { - try { - await syncDefaultProviderToRuntime(providerId, gatewayManager); - } catch (err) { - console.warn('Failed to set OpenClaw default model:', err); - } + await syncDefaultProviderToRuntime(providerId, gatewayManager); } data = { success: true }; @@ -724,12 +695,7 @@ function registerGatewayHandlers(gatewayManager: GatewayManager): void { // Gateway RPC call ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => { try { - const result = await gatewayRpcBackpressure.run( - method, - params, - timeoutMs, - (rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs), - ); + const result = await gatewayManager.rpc(method, params, timeoutMs); return { success: true, result }; } catch (error) { logger.warn(`[gateway:rpc] ${method} failed (timeoutMs=${timeoutMs ?? 30000}): ${String(error)}`); @@ -818,18 +784,6 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ); }; - // Listen for OAuth success to automatically restart the Gateway with new tokens/configs. - // Keep a longer debounce (8s) so provider config writes and OAuth token persistence - // can settle before applying the process-level refresh. - deviceOAuthManager.on('oauth:success', ({ provider, accountId }) => { - logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`); - gatewayManager.debouncedRestart(8000); - }); - browserOAuthManager.on('oauth:success', ({ provider, accountId }) => { - logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`); - gatewayManager.debouncedRestart(8000); - }); - // Get all providers with key info ipcMain.handle('provider:list', async () => { logLegacyProviderChannel('provider:list'); @@ -856,20 +810,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { await providerService.setLegacyProviderApiKey(config.id, trimmedKey); // Also write to OpenClaw auth-profiles.json so the gateway can use it - try { - await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); - } catch (err) { - console.warn('Failed to save key to OpenClaw auth-profiles:', err); - } + await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); } } // Sync the provider configuration to openclaw.json so Gateway knows about it - try { - await syncSavedProviderToRuntime(config, apiKey, gatewayManager); - } catch (err) { - console.warn('Failed to sync openclaw provider config:', err); - } + await syncSavedProviderToRuntime(config, apiKey, gatewayManager); return { success: true }; } catch (error) { @@ -882,16 +828,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { logLegacyProviderChannel('provider:delete'); try { const existing = await providerService.getLegacyProvider(providerId); - await providerService.deleteLegacyProvider(providerId); - - // Best-effort cleanup in OpenClaw auth profiles & openclaw.json config if (existing?.type) { - try { - await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); - } catch (err) { - console.warn('Failed to completely remove provider from OpenClaw:', err); - } + await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); } + await providerService.deleteLegacyProvider(providerId); return { success: true }; } catch (error) { @@ -908,11 +848,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Also write to OpenClaw auth-profiles.json const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; - try { - await syncProviderApiKeyToRuntime(providerType, providerId, apiKey); - } catch (err) { - console.warn('Failed to save key to OpenClaw auth-profiles:', err); - } + await syncProviderApiKeyToRuntime(providerType, providerId, apiKey); return { success: true }; } catch (error) { @@ -961,11 +897,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { } // Sync the provider configuration to openclaw.json so Gateway knows about it - try { - await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); - } catch (err) { - console.warn('Failed to sync openclaw config after provider update:', err); - } + await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); return { success: true }; } catch (error) { @@ -996,11 +928,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Keep OpenClaw auth-profiles.json in sync with local key storage const provider = await providerService.getLegacyProvider(providerId); - try { - await syncDeletedProviderApiKeyToRuntime(provider, providerId); - } catch (err) { - console.warn('Failed to completely remove provider from OpenClaw:', err); - } + await syncDeletedProviderApiKeyToRuntime(provider, providerId); return { success: true }; } catch (error) { @@ -1027,11 +955,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { await providerService.setDefaultLegacyProvider(providerId); // Update OpenClaw config to use this provider's default model - try { - await syncDefaultProviderToRuntime(providerId, gatewayManager); - } catch (err) { - console.warn('Failed to set OpenClaw default model:', err); - } + await syncDefaultProviderToRuntime(providerId, gatewayManager); return { success: true }; } catch (error) { diff --git a/electron/services/agents-api.ts b/electron/services/agents-api.ts index 8600b157..aacd69a5 100644 --- a/electron/services/agents-api.ts +++ b/electron/services/agents-api.ts @@ -27,24 +27,7 @@ function requireString(payload: unknown, key: string): string { return payload[key].trim(); } -function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void { - if (ctx.gatewayManager.getStatus().state !== 'stopped') { - ctx.gatewayManager.debouncedReload(); - return; - } - void reason; -} - -async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise { - try { - await ctx.gatewayManager.restart(); - console.log('[agents] Gateway restart completed after agent deletion'); - } catch (err) { - console.warn('[agents] Gateway restart after agent deletion failed:', err); - } -} - -export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] { +export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] { return { list: async () => ({ success: true, ...(await listAgentsSnapshot()) }), create: async (payload) => { @@ -54,7 +37,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis syncAllProviderAuthToRuntime().catch((err) => { console.warn('[agents] Failed to sync provider auth after agent creation:', err); }); - scheduleGatewayReload(ctx, 'create-agent'); void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => { console.warn('[agents] Failed to ensure ClawX context after agent creation:', err); }); @@ -64,29 +46,19 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis const agentId = requireString(payload, 'id'); const name = requireString(payload, 'name'); const snapshot = await updateAgentName(agentId, name); - scheduleGatewayReload(ctx, 'update-agent'); return { success: true, ...snapshot }; }, updateModel: async (payload) => { const agentId = requireString(payload, 'id'); const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null; const snapshot = await updateAgentModel(agentId, modelRef); - try { - await syncAllProviderAuthToRuntime(); - await syncAgentModelOverrideToRuntime(agentId); - } catch (syncError) { - console.warn('[agents] Failed to sync runtime after updating agent model:', syncError); - } - // Agent model changes must be picked up by the running Gateway before - // the next send; otherwise the UI can show the new selection while the - // active runtime still answers with the previous model. - scheduleGatewayReload(ctx, 'update-agent-model'); + await syncAllProviderAuthToRuntime(); + await syncAgentModelOverrideToRuntime(agentId); return { success: true, ...snapshot }; }, delete: async (payload) => { const agentId = requireString(payload, 'id'); const { snapshot, removedEntry } = await deleteAgentConfig(agentId); - await restartGatewayForAgentDeletion(ctx); await removeAgentWorkspaceDirectory(removedEntry).catch((err) => { console.warn('[agents] Failed to remove workspace after agent deletion:', err); }); @@ -96,7 +68,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis const agentId = requireString(payload, 'id'); const channelType = requireString(payload, 'channelType'); const snapshot = await assignChannelToAgent(agentId, channelType); - scheduleGatewayReload(ctx, 'assign-channel'); return { success: true, ...snapshot }; }, removeChannel: async (payload) => { @@ -122,7 +93,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis await clearChannelBinding(channelType, accountId); } const snapshot = await listAgentsSnapshot(); - scheduleGatewayReload(ctx, 'remove-agent-channel'); return { success: true, ...snapshot }; }, }; diff --git a/electron/services/channels-api.ts b/electron/services/channels-api.ts index bf1baa30..7da6f5c1 100644 --- a/electron/services/channels-api.ts +++ b/electron/services/channels-api.ts @@ -22,6 +22,7 @@ import { assignChannelAccountToAgent, clearAllBindingsForChannel, clearChannelBinding, + ensureScopedChannelBinding as ensureAgentScopedChannelBinding, listAgentsSnapshot, listAgentsSnapshotFromConfig, } from '../utils/agent-config'; @@ -153,11 +154,6 @@ const CHANNEL_TARGET_CACHE_TTL_MS = 60_000; const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true'; const channelTargetCache = new Map(); -const FORCE_RESTART_CHANNELS = new Set([ - 'dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE, - 'discord', 'telegram', 'signal', 'imessage', 'matrix', 'line', 'msteams', 'googlechat', 'mattermost', -]); - function requireString(payload: unknown, key: string): string { if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) { throw new Error(`${key} is required`); @@ -924,83 +920,8 @@ async function listChannelTargetOptions(params: { return targets; } -async function readChannelBindingOwner(channelType: string, accountId?: string): Promise { - const config = await readOpenClawConfig(); - const bindings = Array.isArray((config as { bindings?: unknown }).bindings) - ? (config as { bindings: unknown[] }).bindings - : []; - for (const binding of bindings) { - if (!binding || typeof binding !== 'object') continue; - const candidate = binding as { - agentId?: unknown; - match?: { channel?: unknown; accountId?: unknown } | unknown; - }; - if (typeof candidate.agentId !== 'string' || !candidate.agentId.trim()) continue; - if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) continue; - const match = candidate.match as { channel?: unknown; accountId?: unknown }; - if (match.channel !== channelType) continue; - const bindingAccountId = typeof match.accountId === 'string' ? match.accountId.trim() : ''; - if ((accountId?.trim() || '') !== bindingAccountId) continue; - return candidate.agentId; - } - return null; -} - -async function migrateLegacyChannelWideBinding(channelType: string): Promise { - const explicitDefaultOwner = await readChannelBindingOwner(channelType, 'default'); - const legacyOwner = await readChannelBindingOwner(channelType); - if (!legacyOwner) return; - - const agents = await listAgentsSnapshot(); - const validAgentIds = new Set(agents.agents.map((agent) => agent.id)); - const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner) - ? explicitDefaultOwner - : (legacyOwner && validAgentIds.has(legacyOwner) ? legacyOwner : null); - - if (defaultOwner) { - await assignChannelAccountToAgent(defaultOwner, channelType, 'default'); - } - await clearChannelBinding(channelType); -} - async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise { - const storedChannelType = resolveStoredChannelType(channelType); - if (!accountId) return; - const agents = await listAgentsSnapshot(); - if (!agents.agents || agents.agents.length === 0) return; - - if (accountId === 'default') { - if (agents.agents.some((entry) => entry.id === 'main')) { - await assignChannelAccountToAgent('main', storedChannelType, 'default'); - } - return; - } - - if (agents.agents.some((entry) => entry.id === accountId)) { - await migrateLegacyChannelWideBinding(storedChannelType); - await assignChannelAccountToAgent(accountId, storedChannelType, accountId); - return; - } - - await migrateLegacyChannelWideBinding(storedChannelType); -} - -function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void { - if (ctx.gatewayManager.getStatus().state === 'stopped') return; - ctx.gatewayManager.debouncedRestart(); - void reason; -} - -function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void { - const storedChannelType = resolveStoredChannelType(channelType); - if (ctx.gatewayManager.getStatus().state === 'stopped') return; - if (FORCE_RESTART_CHANNELS.has(storedChannelType)) { - ctx.gatewayManager.debouncedRestart(150); - void reason; - return; - } - ctx.gatewayManager.debouncedReload(150); - void reason; + await ensureAgentScopedChannelBinding(resolveStoredChannelType(channelType), accountId); } function toComparableConfig(input: Record): Record { @@ -1072,7 +993,6 @@ async function awaitWeChatQrLogin( }); await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId); await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId); - scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`); if (activeQrLogins.get(loginKey) !== sessionKey) return; emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', { @@ -1135,7 +1055,6 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR const accountId = requireString(payload, 'accountId'); await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true }); await setChannelDefaultAccount(channelType, accountId); - scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`); return { success: true }; }, bindingSave: async (payload) => { @@ -1148,11 +1067,16 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR throw new Error(`Agent "${agentId}" not found`); } const storedChannelType = resolveStoredChannelType(channelType); - if (accountId !== 'default') { - await migrateLegacyChannelWideBinding(storedChannelType); + if (accountId === 'default') { + await assignChannelAccountToAgent(agentId, storedChannelType, accountId); + } else { + await assignChannelAccountToAgent( + agentId, + storedChannelType, + accountId, + { migrateLegacy: true }, + ); } - await assignChannelAccountToAgent(agentId, storedChannelType, accountId); - scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`); return { success: true }; }, bindingDelete: async (payload) => { @@ -1160,7 +1084,6 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR const accountId = optionalString(payload, 'accountId'); await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true }); await clearChannelBinding(resolveStoredChannelType(channelType), accountId); - scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`); return { success: true }; }, validateConfig: async (payload) => { @@ -1182,19 +1105,16 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR const existingValues = await getChannelFormValues(channelType, accountId); if (isSameConfigValues(existingValues, config)) { await ensureScopedChannelBinding(channelType, accountId); - scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`); return { success: true, noChange: true }; } await saveChannelConfig(channelType, config, accountId); await ensureScopedChannelBinding(channelType, accountId); - scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`); return { success: true }; }, setEnabled: async (payload) => { const channelType = requireString(payload, 'channelType'); const enabled = isRecord(payload) && payload.enabled === true; await setChannelEnabled(channelType, enabled); - scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`); return { success: true }; }, formValues: async (payload) => { @@ -1209,11 +1129,9 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR if (accountId) { await deleteChannelAccountConfig(channelType, accountId); await clearChannelBinding(storedChannelType, accountId); - scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`); } else { await deleteChannelConfig(channelType); await clearAllBindingsForChannel(storedChannelType); - scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`); } return { success: true }; }, diff --git a/electron/services/chat-api.ts b/electron/services/chat-api.ts index e0f183ee..eb0709cb 100644 --- a/electron/services/chat-api.ts +++ b/electron/services/chat-api.ts @@ -1,45 +1,8 @@ import type { BrowserWindow } from 'electron'; import type { GatewayManager } from '../gateway/manager'; import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; -import { logger } from '../utils/logger'; import { createAcpChatService } from './acp-chat-service'; import type { AcpSessionAccessRegistry } from './acp-session-access-registry'; -import { isRecord } from './payload-utils'; - -const VISION_MIME_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/bmp', - 'image/webp', -]); - -type ChatSendWithMediaPayload = { - sessionKey?: unknown; - message?: unknown; - deliver?: unknown; - idempotencyKey?: unknown; - media?: unknown; -}; - -type MediaPayload = { - filePath?: unknown; - mimeType?: unknown; - fileName?: unknown; -}; - -function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: string; fileName: string }> { - if (!Array.isArray(media)) return []; - return media.flatMap((entry): Array<{ filePath: string; mimeType: string; fileName: string }> => { - if (!isRecord(entry)) return []; - const item = entry as MediaPayload; - if (typeof item.filePath !== 'string' || !item.filePath) return []; - return [{ - filePath: item.filePath, - mimeType: typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'application/octet-stream', - fileName: typeof item.fileName === 'string' && item.fileName ? item.fileName : item.filePath.split(/[\\/]/).pop() || 'file', - }]; - }); -} export function createChatApi({ gatewayManager, @@ -53,75 +16,6 @@ export function createChatApi({ const acpChat = createAcpChatService(mainWindow, acpSessionAccessRegistry, gatewayManager); return { - sendWithMedia: async (payload) => { - const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {}; - const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : ''; - const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : ''; - if (!sessionKey || !idempotencyKey) { - return { success: false, error: 'Invalid chat send payload' }; - } - - try { - let message = typeof body.message === 'string' ? body.message : ''; - const imageAttachments: Array> = []; - const fileReferences: string[] = []; - const media = normalizeMedia(body.media); - - if (media.length > 0) { - const fsP = await import('node:fs/promises'); - for (const item of media) { - const exists = await fsP.access(item.filePath).then(() => true, () => false); - logger.info( - `[chat:sendWithMedia] Processing media: name=${item.fileName}, mimeType=${item.mimeType}, exists=${exists}, isVision=${VISION_MIME_TYPES.has(item.mimeType)}`, - ); - - fileReferences.push( - `[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`, - ); - - if (VISION_MIME_TYPES.has(item.mimeType)) { - const fileBuffer = await fsP.readFile(item.filePath); - const base64Data = fileBuffer.toString('base64'); - logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`); - imageAttachments.push({ - content: base64Data, - mimeType: item.mimeType, - fileName: item.fileName, - }); - } - } - } - - if (fileReferences.length > 0) { - const refs = fileReferences.join('\n'); - message = message ? `${message}\n\n${refs}` : refs; - } - - const rpcParams: Record = { - sessionKey, - message, - deliver: body.deliver ?? false, - idempotencyKey, - }; - if (imageAttachments.length > 0) { - rpcParams.attachments = imageAttachments; - } - - logger.info( - `[chat:sendWithMedia] Sending: messageLength=${message.length}, attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`, - ); - const result = await gatewayManager.rpc('chat.send', rpcParams, 120000); - const hasRunId = isRecord(result) && typeof result.runId === 'string'; - logger.info(`[chat:sendWithMedia] RPC result: runId=${hasRunId ? 'present' : 'absent'}`); - const response = hasRunId - ? { runId: result.runId as string } - : undefined; - return { success: true, ...(response ? { result: response } : {}) }; - } catch (error) { - logger.error(`[chat:sendWithMedia] Error: ${String(error)}`); - return { success: false, error: String(error) }; - } - }, loadAcpSession: (payload) => acpChat.loadSession(payload), sendAcpPrompt: (payload) => acpChat.sendPrompt(payload), cancelAcpSession: (payload) => acpChat.cancelSession(payload), diff --git a/electron/services/gateway-api.ts b/electron/services/gateway-api.ts index 8d3959c4..996c4c3c 100644 --- a/electron/services/gateway-api.ts +++ b/electron/services/gateway-api.ts @@ -1,5 +1,4 @@ import type { GatewayManager } from '../gateway/manager'; -import type { GatewayRpcBackpressure } from '../gateway/rpc-backpressure'; import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; import { PORTS } from '../utils/config'; import { approvePendingLocalDeviceRequests } from '../utils/control-ui-device-pairing'; @@ -26,10 +25,7 @@ function parseTimeoutMs(timeoutMs: unknown): number | undefined { return timeoutMs; } -export function createGatewayApi( - gatewayManager: GatewayManager, - gatewayRpcBackpressure: GatewayRpcBackpressure, -): CompleteHostServiceRegistry['gateway'] { +export function createGatewayApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['gateway'] { return { status: () => gatewayManager.getStatus(), start: async () => { @@ -65,12 +61,7 @@ export function createGatewayApi( throw new Error('Invalid gateway RPC method'); } const timeoutMs = parseTimeoutMs(body.timeoutMs); - return gatewayRpcBackpressure.run( - method, - body.params, - timeoutMs, - (rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs), - ); + return gatewayManager.rpc(method, body.params, timeoutMs); }, }; } diff --git a/electron/services/providers-api.ts b/electron/services/providers-api.ts index 93843d04..e373cae4 100644 --- a/electron/services/providers-api.ts +++ b/electron/services/providers-api.ts @@ -216,8 +216,8 @@ async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager const providerId = getProviderId(payload, 'delete'); try { const existing = await providerService._getProviderInternal(providerId); - await providerService._deleteProviderInternal(providerId); await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); + await providerService._deleteProviderInternal(providerId); return { success: true }; } catch (error) { return { success: false, error: String(error) }; @@ -372,12 +372,12 @@ async function deleteAccount( ? 'openai' : undefined; if (apiKeyOnly) { - await providerService._deleteProviderApiKeyInternal(accountId); await syncDeletedProviderApiKeyToRuntime( existing ? providerAccountToConfig(existing) : null, accountId, runtimeProviderKey, ); + await providerService._deleteProviderApiKeyInternal(accountId); return { success: true }; } const currentDefaultAccountId = await providerService.getDefaultAccountId(); @@ -385,10 +385,9 @@ async function deleteAccount( ? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId) : undefined; - await providerService.deleteAccount(accountId); if (replacementDefault) { - await providerService.setDefaultAccount(replacementDefault.id); await syncDefaultProviderToRuntime(replacementDefault.id); + await providerService.setDefaultAccount(replacementDefault.id); } await syncDeletedProviderToRuntime( existing ? providerAccountToConfig(existing) : null, @@ -396,6 +395,7 @@ async function deleteAccount( gatewayManager, runtimeProviderKey, ); + await providerService.deleteAccount(accountId); return { success: true }; } catch (error) { return { success: false, error: String(error) }; diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 19c1e891..290e5af4 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -185,29 +185,6 @@ export async function getProviderFallbackModelRefs(config: ProviderConfig): Prom return results; } -type GatewayRefreshMode = 'reload' | 'restart'; - -function scheduleGatewayRefresh( - gatewayManager: GatewayManager | undefined, - message: string, - options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode }, -): void { - if (!gatewayManager) { - return; - } - - if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') { - return; - } - - logger.info(message); - if (options?.mode === 'restart') { - gatewayManager.debouncedRestart(options?.delayMs); - return; - } - gatewayManager.debouncedReload(options?.delayMs); -} - export async function syncProviderApiKeyToRuntime( providerType: string, providerId: string, @@ -522,29 +499,21 @@ export async function syncAgentModelOverrideToRuntime(agentId: string): Promise< export async function syncSavedProviderToRuntime( config: ProviderConfig, apiKey: string | undefined, - gatewayManager?: GatewayManager, + _gatewayManager?: GatewayManager, ): Promise { const context = await syncProviderToRuntime(config, apiKey); if (!context) { return; } - try { - await syncAgentModelsToRuntime(); - } catch (err) { - logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err); - } + await syncAgentModelsToRuntime(); - scheduleGatewayRefresh( - gatewayManager, - `Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`, - ); } export async function syncUpdatedProviderToRuntime( config: ProviderConfig, apiKey: string | undefined, - gatewayManager?: GatewayManager, + _gatewayManager?: GatewayManager, ): Promise { const context = await syncProviderToRuntime(config, apiKey); if (!context) { @@ -579,22 +548,14 @@ export async function syncUpdatedProviderToRuntime( } } - try { - await syncAgentModelsToRuntime(); - } catch (err) { - logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err); - } + await syncAgentModelsToRuntime(); - scheduleGatewayRefresh( - gatewayManager, - `Scheduling Gateway reload after updating provider "${ock}" config`, - ); } export async function syncDeletedProviderToRuntime( provider: ProviderConfig | null, providerId: string, - gatewayManager?: GatewayManager, + _gatewayManager?: GatewayManager, runtimeProviderKey?: string, ): Promise { if (!provider?.type) { @@ -604,11 +565,6 @@ export async function syncDeletedProviderToRuntime( const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId }); await removeDeletedProviderFromOpenClaw(provider, providerId, ock); - scheduleGatewayRefresh( - gatewayManager, - `Scheduling Gateway restart after deleting provider "${ock}"`, - { mode: 'restart' }, - ); } export async function syncDeletedProviderApiKeyToRuntime( @@ -626,7 +582,7 @@ export async function syncDeletedProviderApiKeyToRuntime( export async function syncDefaultProviderToRuntime( providerId: string, - gatewayManager?: GatewayManager, + _gatewayManager?: GatewayManager, ): Promise { const provider = await getProvider(providerId); if (!provider) { @@ -742,15 +698,7 @@ export async function syncDefaultProviderToRuntime( fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)), ); logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`); - try { - await syncAgentModelsToRuntime(); - } catch (err) { - logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err); - } - scheduleGatewayRefresh( - gatewayManager, - `Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`, - ); + await syncAgentModelsToRuntime(); return; } @@ -775,18 +723,14 @@ export async function syncDefaultProviderToRuntime( logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); - try { - const defaultModelId = provider.model?.split('/').pop(); - await updateAgentModelProvider(targetProviderKey, { - baseUrl, - api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, - apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', - models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [], - }); - } catch (err) { - logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err); - } + const defaultModelId = provider.model?.split('/').pop(); + await updateAgentModelProvider(targetProviderKey, { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [], + }); } if ( @@ -803,15 +747,6 @@ export async function syncDefaultProviderToRuntime( }); } - try { - await syncAgentModelsToRuntime(); - } catch (err) { - logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err); - } + await syncAgentModelsToRuntime(); - scheduleGatewayRefresh( - gatewayManager, - `Scheduling Gateway reload after provider switch to "${ock}"`, - { onlyIfRunning: true }, - ); } diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index 3aeb886f..23f1b616 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -1,9 +1,9 @@ -import { access, copyFile, mkdir, readdir, rm } from 'fs/promises'; -import { constants } from 'fs'; +import { copyFile, lstat, mkdir, readdir, rm } from 'fs/promises'; import { join, normalize } from 'path'; -import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config'; +import { isDeepStrictEqual } from 'node:util'; +import { mutateOpenClawConfig } from '../gateway/config-delivery'; +import { deleteAgentChannelAccounts, listConfiguredChannelsFromConfig, readOpenClawConfig } from './channel-config'; import type { OpenClawConfig } from './channel-config'; -import { withConfigLock } from './config-mutex'; import { expandPath, getOpenClawConfigDir } from './paths'; import * as logger from './logger'; import { toUiChannelType } from './channel-alias'; @@ -62,6 +62,11 @@ interface BindingConfig extends Record { match?: BindingMatch; } +interface ChannelBindingConfig extends BindingConfig { + agentId: string; + match: BindingMatch & { channel: string }; +} + interface ChannelSectionConfig extends Record { accounts?: Record>; defaultAccount?: string; @@ -147,7 +152,7 @@ function slugifyAgentId(name: string): string { async function fileExists(path: string): Promise { try { - await access(path, constants.F_OK); + await lstat(path); return true; } catch { return false; @@ -217,7 +222,7 @@ function normalizeAgentsConfig(config: AgentConfigDocument): { }; } -function isChannelBinding(binding: unknown): binding is BindingConfig { +function isChannelBinding(binding: unknown): binding is ChannelBindingConfig { if (!binding || typeof binding !== 'object') return false; const candidate = binding as BindingConfig; if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false; @@ -460,7 +465,8 @@ function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channel async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedChannels?: string[]): Promise { const { entries, defaultAgentId } = normalizeAgentsConfig(config); - const configuredChannels = preloadedChannels ?? await listConfiguredChannels(); + const configuredChannels = preloadedChannels + ?? await listConfiguredChannelsFromConfig(config as OpenClawConfig); const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings); const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId); const channelOwners: Record = {}; @@ -472,15 +478,11 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha for (const channelType of configuredChannels) { const accountIds = listConfiguredAccountIdsForChannel(config, channelType); let primaryOwner: string | undefined; - const hasExplicitAccountBindingForChannel = accountIds.some((accountId) => - accountToAgent.has(`${channelType}:${accountId}`), - ); - for (const accountId of accountIds) { const owner = accountToAgent.get(`${channelType}:${accountId}`) || ( - accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel + accountId === DEFAULT_ACCOUNT_ID ? channelToAgent.get(channelType) : undefined ); @@ -543,16 +545,25 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha } export async function listAgentsSnapshot(): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig() as AgentConfigDocument; - const { pruneStaleRuntimeAgentModelRefs } = await import('./openclaw-auth'); - const modified = await pruneStaleRuntimeAgentModelRefs(config as unknown as Record); - if (modified) { - await writeOpenClawConfig(config); - logger.info('Pruned stale runtime agent model refs from openclaw.json'); - } - return buildSnapshotFromConfig(config); + let snapshot: AgentsSnapshot | undefined; + let prunedRuntimeModelRefs = false; + const { + getActiveAuthProfileProviders, + pruneStaleRuntimeAgentModelRefs, + } = await import('./openclaw-auth'); + const authProfileProviders = await getActiveAuthProfileProviders(); + await mutateOpenClawConfig(async (configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; + prunedRuntimeModelRefs = await pruneStaleRuntimeAgentModelRefs( + config as unknown as Record, + authProfileProviders, + ); + snapshot = await buildSnapshotFromConfig(config); }); + if (prunedRuntimeModelRefs) { + logger.info('Pruned stale runtime agent model refs from openclaw.json'); + } + return snapshot!; } export async function listAgentsSnapshotFromConfig(config: OpenClawConfig, configuredChannels?: string[]): Promise { @@ -589,8 +600,12 @@ export async function createAgent( name: string, options?: { inheritWorkspace?: boolean }, ): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig() as AgentConfigDocument; + let snapshot: AgentsSnapshot | undefined; + let createdAgentId = ''; + let agentToProvision: AgentListEntry | undefined; + let provisioningConfig: AgentConfigDocument | undefined; + await mutateOpenClawConfig(async (configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config); const normalizedName = normalizeAgentName(name); const existingIds = new Set(entries.map((entry) => entry.id)); @@ -621,18 +636,61 @@ export async function createAgent( list: nextEntries, }; - await provisionAgentFilesystem(config, newAgent, { inheritWorkspace: options?.inheritWorkspace }); - await writeOpenClawConfig(config); - logger.info('Created agent config entry', { agentId: nextId, inheritWorkspace: !!options?.inheritWorkspace }); - return buildSnapshotFromConfig(config); + createdAgentId = nextId; + agentToProvision = newAgent; + provisioningConfig = structuredClone(config); + snapshot = await buildSnapshotFromConfig(config); }); + const createdAgent = agentToProvision!; + const workspaceExisted = await fileExists(expandPath(createdAgent.workspace!)); + const runtimeDirectory = join(getOpenClawConfigDir(), 'agents', createdAgent.id); + const runtimeDirectoryExisted = await fileExists(runtimeDirectory); + try { + await provisionAgentFilesystem(provisioningConfig!, createdAgent, { inheritWorkspace: options?.inheritWorkspace }); + } catch (provisioningError) { + let rollbackError: unknown; + try { + await mutateOpenClawConfig((configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; + const { agentsConfig, entries } = normalizeAgentsConfig(config); + const createdIndex = entries.findIndex((entry) => ( + entry.id === createdAgent.id && isDeepStrictEqual(entry, createdAgent) + )); + if (createdIndex === -1) return; + config.agents = { + ...agentsConfig, + list: entries.filter((_, index) => index !== createdIndex), + }; + }); + } catch (error) { + rollbackError = error; + } + + if (!workspaceExisted) { + await removeAgentWorkspaceDirectory(createdAgent); + } + if (!runtimeDirectoryExisted) { + await removeAgentRuntimeDirectory(createdAgent.id); + } + if (rollbackError) { + throw new AggregateError( + [provisioningError, rollbackError], + `Failed to provision agent "${createdAgent.id}" and roll back its config entry`, + { cause: provisioningError }, + ); + } + throw provisioningError; + } + logger.info('Created agent config entry', { agentId: createdAgentId, inheritWorkspace: !!options?.inheritWorkspace }); + return snapshot!; } export async function updateAgentName(agentId: string, name: string): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig() as AgentConfigDocument; + let snapshot: AgentsSnapshot | undefined; + const normalizedName = normalizeAgentName(name); + await mutateOpenClawConfig(async (configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; const { agentsConfig, entries } = normalizeAgentsConfig(config); - const normalizedName = normalizeAgentName(name); const index = entries.findIndex((entry) => entry.id === agentId); if (index === -1) { throw new Error(`Agent "${agentId}" not found`); @@ -648,10 +706,10 @@ export async function updateAgentName(agentId: string, name: string): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig() as AgentConfigDocument; + const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : ''; + let snapshot: AgentsSnapshot | undefined; + await mutateOpenClawConfig(async (configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; const { agentsConfig, entries } = normalizeAgentsConfig(config); const index = entries.findIndex((entry) => entry.id === agentId); if (index === -1) { throw new Error(`Agent "${agentId}" not found`); } - const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : ''; const nextEntry: AgentListEntry = { ...entries[index] }; if (!normalizedModelRef) { @@ -708,21 +767,24 @@ export async function updateAgentModel(agentId: string, modelRef: string | null) list: entries, }; - await writeOpenClawConfig(config); - logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null }); - return buildSnapshotFromConfig(config); + snapshot = await buildSnapshotFromConfig(config); }); + logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null }); + return snapshot!; } export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> { - return withConfigLock(async () => { - if (agentId === MAIN_AGENT_ID) { - throw new Error('The main agent cannot be deleted'); - } + if (agentId === MAIN_AGENT_ID) { + throw new Error('The main agent cannot be deleted'); + } - const config = await readOpenClawConfig() as AgentConfigDocument; + let result: { snapshot: AgentsSnapshot; removedEntry: AgentListEntry } | undefined; + await mutateOpenClawConfig(async (configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config); - const snapshotBeforeDeletion = await buildSnapshotFromConfig(config); + const bindingsBeforeDeletion = Array.isArray(config.bindings) + ? config.bindings.filter(isChannelBinding) + : []; const removedEntry = entries.find((entry) => entry.id === agentId); const nextEntries = entries.filter((entry) => entry.id !== agentId); if (!removedEntry || nextEntries.length === entries.length) { @@ -746,81 +808,87 @@ export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: Ag const normalizedAgentId = normalizeAgentIdForBinding(agentId); const legacyAccountId = resolveAccountIdForAgent(agentId); + const { channelToAgent, accountToAgent } = getChannelBindingMap(bindingsBeforeDeletion); + const boundChannelTypes = new Set(bindingsBeforeDeletion.map((binding) => binding.match.channel)); const ownedLegacyAccounts = new Set( - Object.entries(snapshotBeforeDeletion.channelAccountOwners) - .filter(([channelAccountKey, owner]) => { - if (owner !== normalizedAgentId) return false; - const accountId = channelAccountKey.slice(channelAccountKey.indexOf(':') + 1); - return accountId === legacyAccountId; + [...boundChannelTypes] + .filter((channelType) => { + const accountOwner = accountToAgent.get(`${channelType}:${legacyAccountId}`); + const effectiveOwner = accountOwner + ?? (legacyAccountId === DEFAULT_ACCOUNT_ID ? channelToAgent.get(channelType) : undefined); + return effectiveOwner === normalizedAgentId; }) - .map(([channelAccountKey]) => channelAccountKey), + .map((channelType) => `${channelType}:${legacyAccountId}`), ); - await writeOpenClawConfig(config); await deleteAgentChannelAccounts(agentId, ownedLegacyAccounts); - await removeAgentRuntimeDirectory(agentId); - // NOTE: workspace directory is NOT deleted here intentionally. - // The caller (route handler) defers workspace removal until after - // the Gateway process has fully restarted, so that any in-flight - // process.chdir(workspace) calls complete before the directory - // disappears (otherwise process.cwd() throws ENOENT for the rest - // of the Gateway's lifetime). - logger.info('Deleted agent config entry', { agentId }); - return { snapshot: await buildSnapshotFromConfig(config), removedEntry }; + result = { snapshot: await buildSnapshotFromConfig(config), removedEntry }; }); + await removeAgentRuntimeDirectory(agentId); + // The caller removes the workspace only after the coordinator commit above. + logger.info('Deleted agent config entry', { agentId }); + return result!; } export async function assignChannelToAgent(agentId: string, channelType: string): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig() as AgentConfigDocument; + let snapshot: AgentsSnapshot | undefined; + const accountId = resolveAccountIdForAgent(agentId); + await mutateOpenClawConfig(async (configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; const { entries } = normalizeAgentsConfig(config); if (!entries.some((entry) => entry.id === agentId)) { throw new Error(`Agent "${agentId}" not found`); } - const accountId = resolveAccountIdForAgent(agentId); config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId); - await writeOpenClawConfig(config); - logger.info('Assigned channel to agent', { agentId, channelType, accountId }); - return buildSnapshotFromConfig(config); + snapshot = await buildSnapshotFromConfig(config); }); + logger.info('Assigned channel to agent', { agentId, channelType, accountId }); + return snapshot!; } export async function assignChannelAccountToAgent( agentId: string, channelType: string, accountId: string, + options?: { migrateLegacy?: boolean }, ): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig() as AgentConfigDocument; + const trimmedAccountId = accountId.trim(); + if (!trimmedAccountId) { + throw new Error('accountId is required'); + } + let snapshot: AgentsSnapshot | undefined; + await mutateOpenClawConfig(async (configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; const { entries } = normalizeAgentsConfig(config); if (!entries.some((entry) => entry.id === agentId)) { throw new Error(`Agent "${agentId}" not found`); } - if (!accountId.trim()) { - throw new Error('accountId is required'); + if (options?.migrateLegacy) { + const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id))); + migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds); } - - config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId.trim()); - await writeOpenClawConfig(config); - logger.info('Assigned channel account to agent', { agentId, channelType, accountId: accountId.trim() }); - return buildSnapshotFromConfig(config); + config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, trimmedAccountId); + snapshot = await buildSnapshotFromConfig(config); }); + logger.info('Assigned channel account to agent', { agentId, channelType, accountId: trimmedAccountId }); + return snapshot!; } export async function clearChannelBinding(channelType: string, accountId?: string): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig() as AgentConfigDocument; + let snapshot: AgentsSnapshot | undefined; + await mutateOpenClawConfig(async (configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId); - await writeOpenClawConfig(config); - logger.info('Cleared channel binding', { channelType, accountId }); - return buildSnapshotFromConfig(config); + snapshot = await buildSnapshotFromConfig(config); }); + logger.info('Cleared channel binding', { channelType, accountId }); + return snapshot!; } export async function clearAllBindingsForChannel(channelType: string): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig() as AgentConfigDocument; + await mutateOpenClawConfig((configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; if (!Array.isArray(config.bindings)) return; const nextBindings = config.bindings.filter((binding) => { @@ -829,7 +897,77 @@ export async function clearAllBindingsForChannel(channelType: string): Promise 0 ? nextBindings : undefined; - await writeOpenClawConfig(config); - logger.info('Cleared all bindings for channel', { channelType }); }); + logger.info('Cleared all bindings for channel', { channelType }); +} + +function migrateLegacyChannelBindingInConfig( + config: AgentConfigDocument, + channelType: string, + validAgentIds: Set, +): void { + const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings); + const legacyOwner = channelToAgent.get(channelType); + if (!legacyOwner) return; + + const explicitDefaultOwner = accountToAgent.get(`${channelType}:${DEFAULT_ACCOUNT_ID}`); + const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner) + ? explicitDefaultOwner + : (validAgentIds.has(legacyOwner) ? legacyOwner : null); + if (defaultOwner) { + config.bindings = upsertBindingsForChannel( + config.bindings, + channelType, + defaultOwner, + DEFAULT_ACCOUNT_ID, + ); + } + config.bindings = upsertBindingsForChannel(config.bindings, channelType, null); +} + +export async function migrateLegacyChannelWideBinding(channelType: string): Promise { + await mutateOpenClawConfig((configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; + const { entries } = normalizeAgentsConfig(config); + const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id))); + migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds); + }); + logger.info('Migrated legacy channel-wide binding', { channelType }); +} + +export async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise { + const normalizedAccountId = accountId?.trim(); + if (!normalizedAccountId) return; + + await mutateOpenClawConfig((configSnapshot) => { + const config = configSnapshot as AgentConfigDocument; + const { entries } = normalizeAgentsConfig(config); + if (entries.length === 0) return; + const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id))); + + if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { + const mainAgent = entries.find((entry) => entry.id === MAIN_AGENT_ID); + if (mainAgent) { + config.bindings = upsertBindingsForChannel( + config.bindings, + channelType, + mainAgent.id, + DEFAULT_ACCOUNT_ID, + ); + } + return; + } + + migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds); + const accountAgent = entries.find((entry) => entry.id === normalizedAccountId); + if (accountAgent) { + config.bindings = upsertBindingsForChannel( + config.bindings, + channelType, + accountAgent.id, + normalizedAccountId, + ); + } + }); + logger.info('Ensured scoped channel binding', { channelType, accountId: normalizedAccountId }); } diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 4c13333e..3ddc005d 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -8,10 +8,10 @@ import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promis import { constants } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; -import { getOpenClawResolvedDir } from './paths'; +import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery'; +import { getOpenClawResolvedDir, resolveOpenClawConfigPath } from './paths'; import * as logger from './logger'; import { proxyAwareFetch } from './proxy-fetch'; -import { withConfigLock } from './config-mutex'; import { OPENCLAW_WECHAT_CHANNEL_TYPE, isWechatChannelType, @@ -20,7 +20,6 @@ import { } from './channel-alias'; const OPENCLAW_DIR = join(homedir(), '.openclaw'); -const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); const WECOM_PLUGIN_ID = 'wecom'; // Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin ID needed. const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE; @@ -154,8 +153,7 @@ function sanitizeDiscordGuilds(config: unknown): void { /** * Strip `defaultAccount` from channel sections whose plugin schema * declares additionalProperties:false without listing `defaultAccount`. - * Call right before every `writeOpenClawConfig` in channel-config - * mutation functions. + * Call before committing channel-config mutations. */ function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void { if (!config.channels) return; @@ -366,6 +364,51 @@ function ensurePluginRegistration(currentConfig: OpenClawConfig, pluginId: strin currentConfig.plugins.entries[pluginId].enabled = true; } +function syncPluginChannelAccountMirror(currentConfig: OpenClawConfig, channelType: string): void { + if (!PLUGIN_CHANNELS.includes(channelType)) return; + const channelSection = currentConfig.channels?.[channelType]; + if (!channelSection) { + removePluginRegistration(currentConfig, channelType); + return; + } + const pluginEntry = currentConfig.plugins?.entries?.[channelType]; + if (!pluginEntry) return; + const accounts = getChannelAccountsMap(channelSection); + pluginEntry.enabled = channelSection.enabled; + pluginEntry.defaultAccount = channelSection.defaultAccount; + if (accounts && Object.keys(accounts).length > 0) { + pluginEntry.accounts = structuredClone(accounts); + } else { + delete pluginEntry.accounts; + } +} + +function deletePluginChannelAccountMirror( + currentConfig: OpenClawConfig, + channelType: string, + accountId: string, +): boolean { + if (!PLUGIN_CHANNELS.includes(channelType)) return false; + const pluginEntry = currentConfig.plugins?.entries?.[channelType]; + if (!pluginEntry) return false; + const accounts = getChannelAccountsMap(pluginEntry); + if (!accounts?.[accountId]) return false; + + delete accounts[accountId]; + const remainingAccountIds = Object.keys(accounts).sort((a, b) => { + if (a === DEFAULT_ACCOUNT_ID) return -1; + if (b === DEFAULT_ACCOUNT_ID) return 1; + return a.localeCompare(b); + }); + if (remainingAccountIds.length === 0) { + delete pluginEntry.accounts; + delete pluginEntry.defaultAccount; + } else if (pluginEntry.defaultAccount === accountId) { + pluginEntry.defaultAccount = remainingAccountIds[0]; + } + return true; +} + function cleanupLegacyBuiltInChannelPluginRegistration( currentConfig: OpenClawConfig, channelType: string, @@ -455,22 +498,10 @@ export interface OpenClawConfig { // ── Config I/O ─────────────────────────────────────────────────── -async function ensureConfigDir(): Promise { - if (!(await fileExists(OPENCLAW_DIR))) { - await mkdir(OPENCLAW_DIR, { recursive: true }); - } -} - export async function readOpenClawConfig(): Promise { - await ensureConfigDir(); - - if (!(await fileExists(CONFIG_FILE))) { - return {}; - } - try { - const content = await readFile(CONFIG_FILE, 'utf-8'); - return JSON.parse(content) as OpenClawConfig; + const snapshot = await readOpenClawConfigSnapshot(); + return snapshot.config as OpenClawConfig; } catch (error) { logger.error('Failed to read OpenClaw config', error); console.error('Failed to read OpenClaw config:', error); @@ -478,26 +509,6 @@ export async function readOpenClawConfig(): Promise { } } -export async function writeOpenClawConfig(config: OpenClawConfig): Promise { - await ensureConfigDir(); - - try { - // Enable graceful in-process reload authorization for SIGUSR1 flows. - const commands = - config.commands && typeof config.commands === 'object' - ? { ...(config.commands as Record) } - : {}; - commands.restart = true; - config.commands = commands; - - await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); - } catch (error) { - logger.error('Failed to write OpenClaw config', error); - console.error('Failed to write OpenClaw config:', error); - throw error; - } -} - // ── Channel operations ─────────────────────────────────────────── async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise { @@ -832,10 +843,12 @@ export async function saveChannelConfig( config: ChannelConfigData, accountId?: string, ): Promise { - return withConfigLock(async () => { - const resolvedChannelType = resolveStoredChannelType(channelType); - const currentConfig = await readOpenClawConfig(); - const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; + const resolvedChannelType = resolveStoredChannelType(channelType); + const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; + let transformedKeys: string[] = []; + + await mutateOpenClawConfig(async (snapshot) => { + const currentConfig = snapshot as OpenClawConfig; cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType); await ensurePluginAllowlist(currentConfig, resolvedChannelType); @@ -859,6 +872,7 @@ export async function saveChannelConfig( const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId); const transformedConfig = transformChannelConfig(resolvedChannelType, config, existingAccountConfig); + transformedKeys = Object.keys(transformedConfig); const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[resolvedChannelType]; if (uniqueKey && typeof transformedConfig[uniqueKey] === 'string') { const rawCredentialValue = transformedConfig[uniqueKey] as string; @@ -920,16 +934,15 @@ export async function saveChannelConfig( } sanitizeChannelSectionsBeforeWrite(currentConfig); - await writeOpenClawConfig(currentConfig); - logger.info('Channel config saved', { - channelType: resolvedChannelType, - accountId: resolvedAccountId, - configFile: CONFIG_FILE, - rawKeys: Object.keys(config), - transformedKeys: Object.keys(transformedConfig), - }); - console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`); }); + logger.info('Channel config saved', { + channelType: resolvedChannelType, + accountId: resolvedAccountId, + configFile: resolveOpenClawConfigPath(), + rawKeys: Object.keys(config), + transformedKeys, + }); + console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`); } export async function getChannelConfig(channelType: string, accountId?: string): Promise { @@ -1003,35 +1016,54 @@ export async function getChannelFormValues(channelType: string, accountId?: stri } export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise { - return withConfigLock(async () => { - const resolvedChannelType = resolveStoredChannelType(channelType); - const currentConfig = await readOpenClawConfig(); + const resolvedChannelType = resolveStoredChannelType(channelType); + let deleteWeChatAccount = false; + let deletedAccount = false; + + await mutateOpenClawConfig((snapshot) => { + deleteWeChatAccount = false; + deletedAccount = false; + const currentConfig = snapshot as OpenClawConfig; + const deletedPluginAccount = deletePluginChannelAccountMirror( + currentConfig, + resolvedChannelType, + accountId, + ); const channelSection = currentConfig.channels?.[resolvedChannelType]; if (!channelSection) { if (isWechatChannelType(resolvedChannelType)) { removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID); - await writeOpenClawConfig(currentConfig); - await deleteWeChatAccountState(accountId); + deleteWeChatAccount = true; + } + if (deletedPluginAccount) { + deletedAccount = true; + syncBuiltinChannelsWithPluginAllowlist(currentConfig); + sanitizeChannelSectionsBeforeWrite(currentConfig); } return; } - - migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); + const existingAccounts = getChannelAccountsMap(channelSection); + const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID + && Object.keys(getLegacyChannelPayload(channelSection)).length > 0; + if (!existingAccounts?.[accountId] && !targetsLegacyDefault) { + if (deletedPluginAccount) { + deletedAccount = true; + syncBuiltinChannelsWithPluginAllowlist(currentConfig); + sanitizeChannelSectionsBeforeWrite(currentConfig); + } + return; + } + const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string' + && channelSection.defaultAccount.trim() + ? channelSection.defaultAccount.trim() + : DEFAULT_ACCOUNT_ID; + migrateLegacyChannelConfigToAccounts(channelSection, currentDefaultAccountId); const accounts = getChannelAccountsMap(channelSection); - if (!accounts?.[accountId]) { - // Account not found; just ensure top-level mirror is consistent - const mirroredAccountId = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() ? channelSection.defaultAccount : DEFAULT_ACCOUNT_ID; - const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID]; - if (defaultAccountData) { - for (const [key, value] of Object.entries(defaultAccountData)) { - channelSection[key] = value; - } - } - return; - } + if (!accounts?.[accountId]) return; delete accounts[accountId]; + deletedAccount = true; if (Object.keys(accounts).length === 0) { delete currentConfig.channels![resolvedChannelType]; @@ -1064,21 +1096,31 @@ export async function deleteChannelAccountConfig(channelType: string, accountId: } } + syncPluginChannelAccountMirror(currentConfig, resolvedChannelType); syncBuiltinChannelsWithPluginAllowlist(currentConfig); sanitizeChannelSectionsBeforeWrite(currentConfig); - await writeOpenClawConfig(currentConfig); if (isWechatChannelType(resolvedChannelType)) { - await deleteWeChatAccountState(accountId); + deleteWeChatAccount = true; } + }); + if (deleteWeChatAccount) { + await deleteWeChatAccountState(accountId); + } + if (deletedAccount) { logger.info('Deleted channel account config', { channelType: resolvedChannelType, accountId }); console.log(`Deleted channel account config for ${resolvedChannelType}/${accountId}`); - }); + } } export async function deleteChannelConfig(channelType: string): Promise { - return withConfigLock(async () => { - const resolvedChannelType = resolveStoredChannelType(channelType); - const currentConfig = await readOpenClawConfig(); + const resolvedChannelType = resolveStoredChannelType(channelType); + let deleteWeChat = false; + let deletedConfig: 'channel' | 'plugin' | undefined; + + await mutateOpenClawConfig((snapshot) => { + deleteWeChat = false; + deletedConfig = undefined; + const currentConfig = snapshot as OpenClawConfig; cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType); if (currentConfig.channels?.[resolvedChannelType]) { @@ -1103,37 +1145,43 @@ export async function deleteChannelConfig(channelType: string): Promise { removePluginRegistration(currentConfig, WECOM_PLUGIN_ID); } syncBuiltinChannelsWithPluginAllowlist(currentConfig); - await writeOpenClawConfig(currentConfig); if (isWechatChannelType(resolvedChannelType)) { - await deleteWeChatState(); + deleteWeChat = true; } - console.log(`Deleted channel config for ${resolvedChannelType}`); + deletedConfig = 'channel'; } else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) { if (currentConfig.plugins?.entries?.[resolvedChannelType] || currentConfig.plugins?.allow?.includes(resolvedChannelType)) { removePluginRegistration(currentConfig, resolvedChannelType); syncBuiltinChannelsWithPluginAllowlist(currentConfig); - await writeOpenClawConfig(currentConfig); - console.log(`Deleted plugin channel config for ${resolvedChannelType}`); + deletedConfig = 'plugin'; } } else if (isWechatChannelType(resolvedChannelType)) { removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID); syncBuiltinChannelsWithPluginAllowlist(currentConfig); - await writeOpenClawConfig(currentConfig); - await deleteWeChatState(); - } - - if (resolvedChannelType === 'whatsapp') { - try { - const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); - if (await fileExists(whatsappDir)) { - await rm(whatsappDir, { recursive: true, force: true }); - console.log('Deleted WhatsApp credentials directory'); - } - } catch (error) { - console.error('Failed to delete WhatsApp credentials:', error); - } + deleteWeChat = true; } }); + + if (deleteWeChat) { + await deleteWeChatState(); + } + if (deletedConfig === 'channel') { + console.log(`Deleted channel config for ${resolvedChannelType}`); + } else if (deletedConfig === 'plugin') { + console.log(`Deleted plugin channel config for ${resolvedChannelType}`); + } + + if (resolvedChannelType === 'whatsapp') { + try { + const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); + if (await fileExists(whatsappDir)) { + await rm(whatsappDir, { recursive: true, force: true }); + console.log('Deleted WhatsApp credentials directory'); + } + } catch (error) { + console.error('Failed to delete WhatsApp credentials:', error); + } + } } function channelHasAnyAccount(channelSection: ChannelConfigData): boolean { @@ -1247,14 +1295,14 @@ export async function listConfiguredChannelAccounts(): Promise { - return withConfigLock(async () => { - const resolvedChannelType = resolveStoredChannelType(channelType); - const trimmedAccountId = accountId.trim(); - if (!trimmedAccountId) { - throw new Error('accountId is required'); - } + const resolvedChannelType = resolveStoredChannelType(channelType); + const trimmedAccountId = accountId.trim(); + if (!trimmedAccountId) { + throw new Error('accountId is required'); + } - const currentConfig = await readOpenClawConfig(); + await mutateOpenClawConfig((snapshot) => { + const currentConfig = snapshot as OpenClawConfig; const channelSection = currentConfig.channels?.[resolvedChannelType]; if (!channelSection) { throw new Error(`Channel "${resolvedChannelType}" is not configured`); @@ -1275,38 +1323,37 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s } sanitizeChannelSectionsBeforeWrite(currentConfig); - await writeOpenClawConfig(currentConfig); - logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId }); }); + logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId }); } export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAccounts?: Set): Promise { - return withConfigLock(async () => { - const currentConfig = await readOpenClawConfig(); - if (!currentConfig.channels) return; + let modified = false; + const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId; - const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId; - let modified = false; + await mutateOpenClawConfig((snapshot) => { + modified = false; + const currentConfig = snapshot as OpenClawConfig; + const channels = currentConfig.channels ?? {}; + for (const channelType of Object.keys(channels)) { + if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) continue; + const section = channels[channelType]; + const existingAccounts = getChannelAccountsMap(section); + const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID + && Object.keys(getLegacyChannelPayload(section)).length > 0; + if (!existingAccounts?.[accountId] && !targetsLegacyDefault) continue; - for (const channelType of Object.keys(currentConfig.channels)) { - const section = currentConfig.channels[channelType]; - migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID); + const currentDefaultAccountId = typeof section.defaultAccount === 'string' + && section.defaultAccount.trim() + ? section.defaultAccount.trim() + : DEFAULT_ACCOUNT_ID; + migrateLegacyChannelConfigToAccounts(section, currentDefaultAccountId); const accounts = getChannelAccountsMap(section); - if (!accounts?.[accountId] || (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`))) { - // Ensure top-level mirror is consistent. - const mirroredAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount : DEFAULT_ACCOUNT_ID; - const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID]; - if (defaultAccountData) { - for (const [key, value] of Object.entries(defaultAccountData)) { - section[key] = value; - } - } - continue; - } + if (!accounts?.[accountId]) continue; delete accounts[accountId]; if (Object.keys(accounts).length === 0) { - delete currentConfig.channels[channelType]; + delete channels[channelType]; } else { if (section.defaultAccount === accountId) { const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => { @@ -1331,21 +1378,37 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc } } } + syncPluginChannelAccountMirror(currentConfig, channelType); modified = true; } + const pluginChannelTypes = ownedChannelAccounts + ? [...ownedChannelAccounts] + .filter((channelAccountKey) => channelAccountKey.endsWith(`:${accountId}`)) + .map((channelAccountKey) => channelAccountKey.slice(0, -accountId.length - 1)) + : Object.keys(currentConfig.plugins?.entries ?? {}); + for (const channelType of pluginChannelTypes) { + if (deletePluginChannelAccountMirror(currentConfig, channelType, accountId)) { + modified = true; + } + } + if (modified) { sanitizeChannelSectionsBeforeWrite(currentConfig); - await writeOpenClawConfig(currentConfig); - logger.info('Deleted all channel accounts for agent', { agentId, accountId }); } }); + if (modified) { + logger.info('Deleted all channel accounts for agent', { agentId, accountId }); + } } export async function setChannelEnabled(channelType: string, enabled: boolean): Promise { - return withConfigLock(async () => { - const resolvedChannelType = resolveStoredChannelType(channelType); - const currentConfig = await readOpenClawConfig(); + const resolvedChannelType = resolveStoredChannelType(channelType); + let pluginChannel = false; + + await mutateOpenClawConfig(async (snapshot) => { + pluginChannel = false; + const currentConfig = snapshot as OpenClawConfig; cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType); if (isWechatChannelType(resolvedChannelType)) { @@ -1357,6 +1420,7 @@ export async function setChannelEnabled(channelType: string, enabled: boolean): } if (PLUGIN_CHANNELS.includes(resolvedChannelType)) { + pluginChannel = true; if (enabled) { ensurePluginRegistration(currentConfig, resolvedChannelType); } else { @@ -1369,8 +1433,6 @@ export async function setChannelEnabled(channelType: string, enabled: boolean): if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`); pluginEntry.enabled = enabled; syncBuiltinChannelsWithPluginAllowlist(currentConfig); - await writeOpenClawConfig(currentConfig); - console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`); return; } @@ -1378,32 +1440,36 @@ export async function setChannelEnabled(channelType: string, enabled: boolean): if (!currentConfig.channels[resolvedChannelType]) currentConfig.channels[resolvedChannelType] = {}; currentConfig.channels[resolvedChannelType].enabled = enabled; syncBuiltinChannelsWithPluginAllowlist(currentConfig, enabled ? [resolvedChannelType] : []); - await writeOpenClawConfig(currentConfig); - console.log(`Set channel ${resolvedChannelType} enabled: ${enabled}`); }); + console.log(`Set ${pluginChannel ? 'plugin channel' : 'channel'} ${resolvedChannelType} enabled: ${enabled}`); } export async function cleanupDanglingWeChatPluginState(): Promise<{ cleanedDanglingState: boolean }> { - return withConfigLock(async () => { - const currentConfig = await readOpenClawConfig(); + let cleanedDanglingState = false; + let hasConfiguredWeChatAccounts = false; + + await mutateOpenClawConfig((snapshot) => { + cleanedDanglingState = false; + hasConfiguredWeChatAccounts = false; + const currentConfig = snapshot as OpenClawConfig; const channelSection = currentConfig.channels?.[WECHAT_PLUGIN_ID]; - const hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection); + hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection); const hadPluginRegistration = Boolean( currentConfig.plugins?.entries?.[WECHAT_PLUGIN_ID] || currentConfig.plugins?.allow?.includes(WECHAT_PLUGIN_ID), ); if (hasConfiguredWeChatAccounts) { - return { cleanedDanglingState: false }; + return; } const modified = removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID); - if (modified) { - await writeOpenClawConfig(currentConfig); - } - await deleteWeChatState(); - return { cleanedDanglingState: hadPluginRegistration || modified }; + cleanedDanglingState = hadPluginRegistration || modified; }); + if (!hasConfiguredWeChatAccounts) { + await deleteWeChatState(); + } + return { cleanedDanglingState }; } // ── Validation ─────────────────────────────────────────────────── diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index c4260a98..dfc4f13c 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -28,7 +28,11 @@ import { isOpenClawOAuthPluginProviderKey, } from './provider-keys'; import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost'; -import { withConfigLock } from './config-mutex'; +import { + mutateOpenClawConfig, + readOpenClawConfigSnapshot, + reloadOpenClawSecretsIfRunning, +} from '../gateway/config-delivery'; import { ensureMemorySearchFtsDefault, hasUserMemorySearchConfig, @@ -430,12 +434,6 @@ async function readAuthProfiles(agentId = 'main'): Promise { const jsonStore = await readAuthProfilesJson(agentId); if (jsonStore?.profiles && Object.keys(jsonStore.profiles).length > 0) { - try { - writeAuthProfilesToSqlite(jsonStore, agentId); - console.log(`[auth-sync] Backfilled SQLite auth store from JSON for agent "${agentId}"`); - } catch (error) { - console.warn(`Failed to backfill SQLite auth store for agent "${agentId}":`, error); - } return jsonStore; } @@ -444,19 +442,27 @@ async function readAuthProfiles(agentId = 'main'): Promise { async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise { writeAuthProfilesToSqlite(store, agentId); - await writeJsonFile(getAuthProfilesPath(agentId), store); + try { + await writeJsonFile(getAuthProfilesPath(agentId), store); + } catch (error) { + console.warn(`Failed to update compatibility auth-profiles.json for agent "${agentId}":`, error); + } } /** Migrate legacy JSON-only auth profiles into SQLite for all configured agents. */ export async function migrateAllAgentAuthProfilesToSqlite(): Promise { const agentIds = await discoverAgentIds(); + let migrated = false; for (const agentId of agentIds) { try { - await migrateAuthProfilesJsonToSqliteIfNeeded(agentId); + migrated = await migrateAuthProfilesJsonToSqliteIfNeeded(agentId) || migrated; } catch (error) { console.warn(`Failed to migrate auth profiles to SQLite for agent "${agentId}":`, error); } } + if (migrated) { + await reloadOpenClawSecretsIfRunning(); + } } function getApiKeyFromAuthProfilesStore( @@ -524,7 +530,6 @@ async function discoverAgentIds(): Promise { // ── OpenClaw Config Helpers ────────────────────────────────────── -const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const; const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']); /** Matches OpenClaw's 200k+ context-window recommendation (see computeContextAwareReserveTokensFloor). */ @@ -691,8 +696,11 @@ async function getProvidersFromAuthProfileStores( return providers; } -async function collectActiveProviderIdsFromConfig(config: Record): Promise> { - const activeProviders = new Set(); +function collectActiveProviderIdsFromConfig( + config: Record, + authProfileProviders: Iterable = [], +): Set { + const activeProviders = new Set(authProfileProviders); const providers = (config.models as Record | undefined)?.providers; if (providers && typeof providers === 'object') { for (const key of Object.keys(providers as Record)) { @@ -724,11 +732,6 @@ async function collectActiveProviderIdsFromConfig(config: Record> { - return (await readJsonFile>(OPENCLAW_CONFIG_PATH)) ?? {}; + return (await readOpenClawConfigSnapshot()).config; } async function resolveInstalledFeishuPluginId(): Promise { @@ -937,21 +940,6 @@ function backfillCustomProviderModelContextWindows(config: Record): Promise { - normalizeAgentsDefaultsCompactionMode(config); - - // Ensure SIGUSR1 graceful reload is authorized by OpenClaw config. - const commands = ( - config.commands && typeof config.commands === 'object' - ? { ...(config.commands as Record) } - : {} - ) as Record; - commands.restart = true; - config.commands = commands; - - await writeJsonFile(OPENCLAW_CONFIG_PATH, config); -} - // ── Exported Functions (all async) ─────────────────────────────── /** @@ -998,6 +986,7 @@ export async function saveOAuthTokenToOpenClaw( await writeAuthProfiles(store, id); } + await reloadOpenClawSecretsIfRunning(); console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } @@ -1059,6 +1048,7 @@ export async function saveProviderKeyToOpenClaw( await writeAuthProfiles(store, id); } + await reloadOpenClawSecretsIfRunning(); console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } @@ -1071,13 +1061,18 @@ export async function removeProviderKeyFromOpenClaw( ): Promise { const agentIds = agentId ? [agentId] : await discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); + let modified = false; for (const id of agentIds) { const store = await readAuthProfiles(id); if (removeProfileFromStore(store, `${provider}:default`, 'api_key')) { await writeAuthProfiles(store, id); + modified = true; } } + if (modified) { + await reloadOpenClawSecretsIfRunning(); + } console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } @@ -1135,7 +1130,6 @@ function isRuntimeGeneratedProviderKey(providerKey: string): boolean { function pruneStaleRuntimeModelConfig( modelCfg: Record, activeProviders: Set, - context: string, ): boolean { let modified = false; const primary = typeof modelCfg.primary === 'string' ? modelCfg.primary.trim() : ''; @@ -1148,7 +1142,6 @@ function pruneStaleRuntimeModelConfig( ) { delete modelCfg.primary; modified = true; - console.log(`Removed stale runtime model ref "${primary}" from ${context}`); } } @@ -1172,8 +1165,13 @@ function pruneStaleRuntimeModelConfig( * Drop agent model refs that point at deleted custom/ollama runtime providers. * Built-in providers are left intact because they may still resolve via auth/env. */ -export async function pruneStaleRuntimeAgentModelRefs(config: Record): Promise { - const activeProviders = await getActiveOpenClawProviders(); +export async function pruneStaleRuntimeAgentModelRefs( + config: Record, + authProfileProviders?: Iterable, +): Promise { + const activeProviders = authProfileProviders + ? collectActiveProviderIdsFromConfig(config, authProfileProviders) + : await getActiveOpenClawProviders(); const agents = config.agents; if (!isPlainRecord(agents)) return false; @@ -1181,7 +1179,7 @@ export async function pruneStaleRuntimeAgentModelRefs(config: Record { - // 1. Remove from auth-profiles.json. - // We must also remove entries whose raw `provider` field maps to this UI - // provider key via AUTH_PROFILE_PROVIDER_KEY_MAP (e.g. "openai-codex" → "openai"). - // If those entries survive, getProvidersFromAuthProfileStores() will re-add - // the provider and trigger a re-seed loop in listAccounts(). const providerKeysToRemove = expandProviderKeysForDeletion(provider); const agentIds = await discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); - for (const id of agentIds) { - const store = await readAuthProfiles(id); - let storeModified = false; - for (const key of providerKeysToRemove) { - if (removeProfilesForProvider(store, key)) { - storeModified = true; - } - } - if (storeModified) { - await writeAuthProfiles(store, id); - } - } - - // 2. Remove from models.json (per-agent model registry used by pi-ai directly) - for (const id of agentIds) { - const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json'); - try { - if (await fileExists(modelsPath)) { - const raw = await readFile(modelsPath, 'utf-8'); - const data = JSON.parse(raw) as Record; - const providers = data.providers as Record | undefined; - if (providers && providers[provider]) { - delete providers[provider]; - await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8'); - console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`); - } - } - } catch (err) { - console.warn(`Failed to remove provider ${provider} from models.json (agent "${id}"):`, err); - } - } - - // 3. Remove from openclaw.json - try { - await withConfigLock(async () => { - const config = await readOpenClawJson(); + let authProfilesModified = false; + // Commit the authoritative config first. If this fails, sidecar credentials + // and model registries remain untouched and the caller can safely retry. + await mutateOpenClawConfig(async (config) => { let modified = false; // Remove plugin registrations for OAuth providers (e.g. MiniMax). @@ -1253,7 +1213,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise; + const providers = data.providers as Record | undefined; + if (providers && providers[provider]) { + delete providers[provider]; + await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8'); + console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`); + } + } + + // Remove auth entries whose raw provider maps to this UI provider key + // (for example "openai-codex" -> "openai"). Keep this last so every + // successful auth batch can immediately refresh the running snapshot. + let authWriteError: unknown; + try { + for (const id of agentIds) { + const store = await readAuthProfiles(id); + let storeModified = false; + for (const key of providerKeysToRemove) { + if (removeProfilesForProvider(store, key)) { + storeModified = true; + } + } + if (storeModified) { + await writeAuthProfiles(store, id); + authProfilesModified = true; + } + } + } catch (error) { + authWriteError = error; + } + if (authProfilesModified) { + try { + await reloadOpenClawSecretsIfRunning(); + } catch (reloadError) { + if (authWriteError) { + throw new AggregateError( + [authWriteError, reloadError], + `Failed to remove provider "${provider}" auth profiles and refresh OpenClaw secrets`, + { cause: reloadError }, + ); + } + throw reloadError; + } + } + if (authWriteError) { + throw authWriteError; } } @@ -1452,8 +1456,8 @@ function migrateOpenAiCodexOAuthRuntimeToOpenAiInConfig(config: Record { const removed: string[] = []; - await withConfigLock(async () => { - const config = await readOpenClawJson(); + await mutateOpenClawConfig((config) => { + removed.length = 0; const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; let modified = false; @@ -1485,7 +1489,7 @@ export async function pruneInvalidApiProviderEntries(): Promise { if (modified) { models.providers = providers; config.models = models; - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); } }); return removed; @@ -1515,8 +1519,7 @@ export async function setOpenClawDefaultModel( modelOverride?: string, fallbackModels: string[] = [] ): Promise { - return withConfigLock(async () => { - const config = await readOpenClawJson(); + await mutateOpenClawConfig((config) => { ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const model = normalizeModelRef(provider, modelOverride); @@ -1596,7 +1599,7 @@ export async function setOpenClawDefaultModel( if (!gateway.mode) gateway.mode = 'local'; config.gateway = gateway; - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`); }); } @@ -1805,8 +1808,8 @@ function healAnthropicMessagesMaxTokensInConfig(config: Record) */ export async function ensureAnthropicMessagesModelMaxTokens(): Promise { const healed: string[] = []; - await withConfigLock(async () => { - const config = await readOpenClawJson(); + await mutateOpenClawConfig((config) => { + healed.length = 0; const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; let modified = false; @@ -1825,7 +1828,7 @@ export async function ensureAnthropicMessagesModelMaxTokens(): Promise if (modified) { models.providers = providers; config.models = models; - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); } }); return healed; @@ -1991,12 +1994,11 @@ function upsertOpenClawProviderEntry( */ export async function ensureOpenClawProviderAgentRuntimePins(): Promise { let pinned: string[] = []; - await withConfigLock(async () => { - const config = await readOpenClawJson(); + await mutateOpenClawConfig((config) => { pinned = applyOpenClawProviderAgentRuntimePinsToConfig(config); if (pinned.length > 0) { - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); } }); return pinned; @@ -2087,8 +2089,7 @@ export async function syncProviderConfigToOpenClaw( modelId: string | undefined, override: RuntimeProviderConfigOverride ): Promise { - return withConfigLock(async () => { - const config = await readOpenClawJson(); + await mutateOpenClawConfig((config) => { ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); if (override.baseUrl && override.api) { @@ -2109,7 +2110,7 @@ export async function syncProviderConfigToOpenClaw( ensureOAuthPluginEnabled(config, provider); } - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); }); } @@ -2177,9 +2178,7 @@ export async function syncOpenAiCompatibleImageRelay(params: { apiKey?: string; imageModelIds?: string[]; }): Promise { - return withConfigLock(async () => { - const config = await readOpenClawJson(); - + await mutateOpenClawConfig((config) => { if (!params.enabled) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; @@ -2196,15 +2195,24 @@ export async function syncOpenAiCompatibleImageRelay(params: { const primary = typeof imageGenerationModel?.primary === 'string' ? imageGenerationModel.primary.trim().toLowerCase() : ''; - if (defaults && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) { - delete defaults.imageGenerationModel; + if (defaults && imageGenerationModel && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) { + const remainingFallbacks = Array.isArray(imageGenerationModel.fallbacks) + ? imageGenerationModel.fallbacks.filter((fallback): fallback is string => ( + typeof fallback === 'string' + && !fallback.trim().toLowerCase().startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`) + )) + : []; + if (remainingFallbacks.length > 0) { + imageGenerationModel.primary = remainingFallbacks.shift(); + } else { + delete imageGenerationModel.primary; + } + if (Array.isArray(imageGenerationModel.fallbacks)) { + imageGenerationModel.fallbacks = remainingFallbacks; + } } removePluginRegistrations(config, [CLAWX_OPENAI_IMAGE_PROVIDER_KEY]); - await writeOpenClawJson(config); - await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY); - if (params.apiKey?.trim()) { - await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim()); - } + normalizeAgentsDefaultsCompactionMode(config); return; } @@ -2215,6 +2223,12 @@ export async function syncOpenAiCompatibleImageRelay(params: { if (modelIds.length === 0) { modelIds.push(CLAWX_OPENAI_IMAGE_DEFAULT_MODEL); } + const existingModels = readModelsProvider(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY)?.models; + const existingModelsById = new Map( + (Array.isArray(existingModels) ? existingModels : []) + .filter((model): model is Record => isPlainRecord(model) && typeof model.id === 'string') + .map((model) => [model.id as string, model]), + ); upsertOpenClawProviderEntry(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY, { baseUrl, api: 'openai-completions', @@ -2222,13 +2236,24 @@ export async function syncOpenAiCompatibleImageRelay(params: { mergeExistingModels: false, request: { allowPrivateNetwork: true }, }); - ensurePluginRegistrationEnabled(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY); - await writeOpenClawJson(config); - - if (params.apiKey?.trim()) { - await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim()); + const relayProvider = readModelsProvider(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY); + if (relayProvider && Array.isArray(relayProvider.models)) { + relayProvider.models = relayProvider.models.map((model) => { + if (!isPlainRecord(model) || typeof model.id !== 'string') return model; + const existing = existingModelsById.get(model.id); + return existing ? { ...model, ...existing, id: model.id } : model; + }); } + ensurePluginRegistrationEnabled(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY); + normalizeAgentsDefaultsCompactionMode(config); }); + + if (!params.enabled) { + await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY); + } + if (params.apiKey?.trim()) { + await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim()); + } } export function readOpenAiCompatibleImageRelayState( @@ -2259,8 +2284,7 @@ export async function setOpenClawDefaultModelWithOverride( override: RuntimeProviderConfigOverride, fallbackModels: string[] = [] ): Promise { - return withConfigLock(async () => { - const config = await readOpenClawJson(); + await mutateOpenClawConfig((config) => { ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const model = normalizeModelRef(provider, modelOverride); @@ -2304,7 +2328,7 @@ export async function setOpenClawDefaultModelWithOverride( ensureOAuthPluginEnabled(config, provider); } - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); console.log( `Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)` ); @@ -2319,67 +2343,24 @@ export async function setOpenClawDefaultModelWithOverride( // These may still linger in openclaw.json from older versions. const DEPRECATED_PROVIDER_IDS = new Set(['qwen-portal']); +export async function getActiveAuthProfileProviders(): Promise> { + return await getProvidersFromAuthProfileStores({ includeRawKeys: true }); +} + export async function getActiveOpenClawProviders(): Promise> { - const activeProviders = new Set(); - try { - const config = await readOpenClawJson(); - - // 1. models.providers - const providers = (config.models as Record | undefined)?.providers; - if (providers && typeof providers === 'object') { - for (const key of Object.keys(providers as Record)) { - activeProviders.add(key); - } - } - - // 2. plugins.entries for OAuth providers - const plugins = (config.plugins as Record | undefined)?.entries; - if (plugins && typeof plugins === 'object') { - for (const [pluginId, meta] of Object.entries(plugins as Record)) { - if (pluginId.endsWith('-auth') && (meta as Record).enabled) { - activeProviders.add(pluginId.replace(/-auth$/, '')); - } - } - } - - // 3. agents.defaults.model.primary — the default model reference encodes - // the provider prefix (e.g. "modelstudio/qwen3.6-plus" → "modelstudio"). - // This covers providers that are active via OAuth or env-key but don't - // have an explicit models.providers entry. - const agents = config.agents as Record | undefined; - const defaults = agents?.defaults as Record | undefined; - const modelConfig = defaults?.model as Record | undefined; - const primaryModel = typeof modelConfig?.primary === 'string' ? modelConfig.primary : undefined; - if (primaryModel?.includes('/')) { - activeProviders.add(primaryModel.split('/')[0]); - } - - // 4. auth.profiles — OAuth/device-token based providers may exist only in - // auth-profiles without explicit models.providers entries yet. - // Raw keys (e.g. "openai-codex") are included so downstream logic can - // distinguish OAuth runtime providers from their UI alias ("openai"). - const auth = config.auth as Record | undefined; - addProvidersFromProfileEntries( - auth?.profiles as Record | undefined, - activeProviders, - { includeRawKeys: true }, + const [config, authProfileProviders] = await Promise.all([ + readOpenClawJson(), + getActiveAuthProfileProviders(), + ]); + return collectActiveProviderIdsFromConfig( + config, + authProfileProviders, ); - - const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true }); - for (const provider of authProfileProviders) { - activeProviders.add(provider); - } } catch (err) { console.warn('Failed to read openclaw.json for active providers:', err); + return new Set(); } - - // Remove deprecated providers that may still linger in config/auth files. - for (const deprecated of DEPRECATED_PROVIDER_IDS) { - activeProviders.delete(deprecated); - } - - return activeProviders; } /** @@ -2448,9 +2429,8 @@ function applyControlUiAllowedOrigins(controlUi: Record, port: * Write the ClawX gateway token into ~/.openclaw/openclaw.json. */ export async function syncGatewayTokenToConfig(token: string): Promise { - return withConfigLock(async () => { - const config = await readOpenClawJson(); - + const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY; + await mutateOpenClawConfig((config) => { const gateway = ( config.gateway && typeof config.gateway === 'object' ? { ...(config.gateway as Record) } @@ -2472,16 +2452,15 @@ export async function syncGatewayTokenToConfig(token: string): Promise { ? { ...(gateway.controlUi as Record) } : {} ) as Record; - const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY; applyControlUiAllowedOrigins(controlUi, gatewayPort); gateway.controlUi = controlUi; if (!gateway.mode) gateway.mode = 'local'; config.gateway = gateway; - await writeOpenClawJson(config); - console.log('Synced gateway token to openclaw.json'); + normalizeAgentsDefaultsCompactionMode(config); }); + console.log('Synced gateway token to openclaw.json'); } /** @@ -2535,9 +2514,7 @@ function ensureWebFetchSsrfPolicyInConfig(config: Record): bool * Ensure browser automation is enabled in ~/.openclaw/openclaw.json. */ export async function syncBrowserConfigToOpenClaw(): Promise { - return withConfigLock(async () => { - const config = await readOpenClawJson(); - + await mutateOpenClawConfig((config) => { const browser = ( config.browser && typeof config.browser === 'object' ? { ...(config.browser as Record) } @@ -2573,7 +2550,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise { if (!changed) return; config.browser = browser; - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); console.log('Synced browser and web_fetch config to openclaw.json'); }); } @@ -2591,9 +2568,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise { export async function syncSessionIdleMinutesToOpenClaw(): Promise { const DEFAULT_IDLE_MINUTES = 10_080; // 7 days - return withConfigLock(async () => { - const config = await readOpenClawJson(); - + await mutateOpenClawConfig((config) => { const session = ( config.session && typeof config.session === 'object' ? { ...(config.session as Record) } @@ -2612,22 +2587,36 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise { session.idleMinutes = DEFAULT_IDLE_MINUTES; config.session = session; - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); console.log(`Synced session.idleMinutes=${DEFAULT_IDLE_MINUTES} (7d) to openclaw.json`); }); } /** * Batch-apply gateway token, browser config, and session idle minutes in a - * single config lock + read + write cycle. Replaces three separate - * withConfigLock calls during pre-launch sync. + * single coordinator transaction. Replaces three separate config mutations + * during pre-launch sync. */ export async function batchSyncConfigFields(token: string): Promise { const DEFAULT_IDLE_MINUTES = 10_080; // 7 days + const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY; + const memorySearchMigrationVersion = Number( + await getSetting('memorySearchFtsMigrationVersion'), + ) || 0; + const shouldMigrateLegacyMemorySearch = + memorySearchMigrationVersion < MEMORY_SEARCH_FTS_MIGRATION_VERSION; + const hasOpenAiEmbeddingKey = Boolean(await getProviderApiKeyFromOpenClaw('openai')); + let pinnedProviderRuntimes: string[] = []; + let compactionLog: string | undefined; + let memorySearchDefaultResult: 'migrated' | 'seeded' | 'unchanged' = 'unchanged'; + let backfilledContextWindows: string[] = []; - return withConfigLock(async () => { - const config = await readOpenClawJson(); + const changed = await mutateOpenClawConfig((config) => { let modified = true; + pinnedProviderRuntimes = []; + compactionLog = undefined; + memorySearchDefaultResult = 'unchanged'; + backfilledContextWindows = []; // ── Gateway token + controlUi ── const gateway = ( @@ -2650,7 +2639,6 @@ export async function batchSyncConfigFields(token: string): Promise { ? { ...(gateway.controlUi as Record) } : {} ) as Record; - const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY; applyControlUiAllowedOrigins(controlUi, gatewayPort); gateway.controlUi = controlUi; if (!gateway.mode) gateway.mode = 'local'; @@ -2691,10 +2679,9 @@ export async function batchSyncConfigFields(token: string): Promise { modified = true; } - const pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config); + pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config); if (pinnedProviderRuntimes.length > 0) { modified = true; - console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`); } // ── Session idle minutes ── @@ -2716,58 +2703,65 @@ export async function batchSyncConfigFields(token: string): Promise { // ── Compaction safeguard default ── if (ensureCompactionSafeguardDefault(config)) { modified = true; - console.log(`[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`); + compactionLog = `[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`; } else if (backfillCompactionReserveTokensFloor(config)) { modified = true; - console.log(`[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`); + compactionLog = `[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`; } // ── Memory search default ── // OpenClaw 2026.7.1 supports provider=none as an explicit FTS-only mode. // Migrate ClawX's exact legacy disabled default once, and otherwise seed // FTS only when the user has no memorySearch config or OpenAI embedding key. - const memorySearchMigrationVersion = Number( - await getSetting('memorySearchFtsMigrationVersion'), - ) || 0; - const shouldMigrateLegacyMemorySearch = - memorySearchMigrationVersion < MEMORY_SEARCH_FTS_MIGRATION_VERSION; - let memorySearchDefaultResult = shouldMigrateLegacyMemorySearch + memorySearchDefaultResult = shouldMigrateLegacyMemorySearch && hasUserMemorySearchConfig(config) ? ensureMemorySearchFtsDefault(config, true) : 'unchanged'; if (memorySearchDefaultResult === 'unchanged' && !hasUserMemorySearchConfig(config) - && !(await getProviderApiKeyFromOpenClaw('openai'))) { + && !hasOpenAiEmbeddingKey) { memorySearchDefaultResult = ensureMemorySearchFtsDefault(config); } if (memorySearchDefaultResult !== 'unchanged') { modified = true; - console.log( - `[batch-sync] ${memorySearchDefaultResult === 'migrated' ? 'Migrated' : 'Seeded'} ` - + 'agents.defaults.memorySearch to FTS-only mode', - ); } // ── Custom provider contextWindow backfill ── - const backfilledContextWindows = backfillCustomProviderModelContextWindows(config); + backfilledContextWindows = backfillCustomProviderModelContextWindows(config); if (backfilledContextWindows.length > 0) { modified = true; - console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`); } if (modified) { - await writeOpenClawJson(config); - console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json'); - } - if (shouldMigrateLegacyMemorySearch) { - await setSetting( - 'memorySearchFtsMigrationVersion', - MEMORY_SEARCH_FTS_MIGRATION_VERSION, - ); + normalizeAgentsDefaultsCompactionMode(config); } }); + if (pinnedProviderRuntimes.length > 0) { + console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`); + } + if (compactionLog) { + console.log(compactionLog); + } + if (memorySearchDefaultResult !== 'unchanged') { + console.log( + `[batch-sync] ${memorySearchDefaultResult === 'migrated' ? 'Migrated' : 'Seeded'} ` + + 'agents.defaults.memorySearch to FTS-only mode', + ); + } + if (backfilledContextWindows.length > 0) { + console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`); + } + if (changed) { + console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json'); + } + if (shouldMigrateLegacyMemorySearch) { + await setSetting( + 'memorySearchFtsMigrationVersion', + MEMORY_SEARCH_FTS_MIGRATION_VERSION, + ); + } } /** @@ -2913,25 +2907,22 @@ function ensureToolDenyIncludes( } export async function sanitizeOpenClawConfig(): Promise { - return withConfigLock(async () => { - // Skip sanitization if the config file does not exist yet. - // Creating a skeleton config here would overwrite any data written - // by the Gateway on its first run. - if (!(await fileExists(OPENCLAW_CONFIG_PATH))) { - console.log('[sanitize] openclaw.json does not exist yet, skipping sanitization'); - return; - } + // The prelaunch file fallback must not turn a missing or corrupt config into + // a valid-looking skeleton. The coordinator performs the successful mutation. + let sourceExists: boolean; + try { + sourceExists = (await readOpenClawConfigSnapshot()).exists; + } catch { + console.log('[sanitize] openclaw.json could not be parsed, skipping sanitization to preserve data'); + return; + } + if (!sourceExists) { + console.log('[sanitize] openclaw.json does not exist yet, skipping sanitization'); + return; + } + const authProfileProviders = await getActiveAuthProfileProviders(); - // Read the raw file directly instead of going through readOpenClawJson() - // which coalesces null → {}. We need to distinguish a genuinely empty - // file (valid, proceed normally) from a corrupt/unreadable file (null, - // bail out to avoid overwriting the user's data with a skeleton config). - const rawConfig = await readJsonFile>(OPENCLAW_CONFIG_PATH); - if (rawConfig === null) { - console.log('[sanitize] openclaw.json could not be parsed, skipping sanitization to preserve data'); - return; - } - const config: Record = rawConfig; + await mutateOpenClawConfig(async (config) => { let modified = false; // ── skills section ────────────────────────────────────────────── @@ -3022,20 +3013,6 @@ export async function sanitizeOpenClawConfig(): Promise { } } - // ── commands section ─────────────────────────────────────────── - // Required for SIGUSR1 in-process reload authorization. - const commands = ( - config.commands && typeof config.commands === 'object' - ? { ...(config.commands as Record) } - : {} - ) as Record; - if (commands.restart !== true) { - commands.restart = true; - config.commands = commands; - modified = true; - console.log('[sanitize] Enabling commands.restart for graceful reload support'); - } - // ── tools.web.search.kimi ───────────────────────────────────── // OpenClaw moved moonshot web search config under // plugins.entries.moonshot.config.webSearch. Migrate the old key and strip @@ -3512,7 +3489,7 @@ export async function sanitizeOpenClawConfig(): Promise { const bundled = discoverBundledPlugins(); const installedExtensionIds = await discoverInstalledExtensionPluginIds(); const loadedPluginIds = await discoverLoadedPluginIdsFromConfig(config); - const activeProviderIds = await collectActiveProviderIdsFromConfig(config); + const activeProviderIds = collectActiveProviderIdsFromConfig(config, authProfileProviders); const explicitlyEnabledBundledPluginIds = Object.keys(pEntries) .filter((pluginId) => { @@ -3721,7 +3698,7 @@ export async function sanitizeOpenClawConfig(): Promise { } if (modified) { - await writeOpenClawJson(config); + normalizeAgentsDefaultsCompactionMode(config); console.log('[sanitize] openclaw.json sanitized successfully'); } }); diff --git a/electron/utils/openclaw-image-generation.ts b/electron/utils/openclaw-image-generation.ts index 267d4818..d24e5581 100644 --- a/electron/utils/openclaw-image-generation.ts +++ b/electron/utils/openclaw-image-generation.ts @@ -1,8 +1,8 @@ /** * Read/write agents.defaults.imageGenerationModel and per-agent auth readiness. */ -import { readOpenClawConfig, writeOpenClawConfig } from './channel-config'; -import { withConfigLock } from './config-mutex'; +import { mutateOpenClawConfig } from '../gateway/config-delivery'; +import { readOpenClawConfig } from './channel-config'; import { getOAuthTokenFromOpenClaw, getProviderApiKeyFromOpenClaw, @@ -10,7 +10,11 @@ import { syncOpenAiCompatibleImageRelay, } from './openclaw-auth'; import { ensureClawXOpenAiImagePluginInstalled } from './plugin-install'; -import { listAgentsSnapshot, type AgentsSnapshot } from './agent-config'; +import { + listAgentsSnapshot, + listAgentsSnapshotFromConfig, + type AgentsSnapshot, +} from './agent-config'; import { expandPath } from './paths'; import { generateImageInProcess, @@ -84,6 +88,7 @@ type AgentModelConfigShape = { primary?: string; fallbacks?: string[]; timeoutMs?: number; + [key: string]: unknown; }; function isRecord(value: unknown): value is Record { @@ -130,12 +135,12 @@ function parseImageGenerationModelConfig(raw: unknown): ImageGenerationModelConf function buildImageGenerationModelConfigWrite( config: ImageGenerationModelConfig, + existing: unknown, ): AgentModelConfigShape | undefined { - if (!config.primary && config.fallbacks.length === 0 && config.timeoutMs === null) { - return undefined; - } - - const next: AgentModelConfigShape = {}; + const next: AgentModelConfigShape = isRecord(existing) ? { ...existing } : {}; + delete next.primary; + delete next.fallbacks; + delete next.timeoutMs; if (config.primary) { next.primary = config.primary; } @@ -145,7 +150,7 @@ function buildImageGenerationModelConfigWrite( if (config.timeoutMs !== null) { next.timeoutMs = config.timeoutMs; } - return next; + return Object.keys(next).length > 0 ? next : undefined; } export function parseProviderFromModelRef(modelRef: string): string | null { @@ -207,8 +212,8 @@ export async function setImageGenerationConfig( } } - return withConfigLock(async () => { - const config = await readOpenClawConfig(); + let savedConfig: ImageGenerationModelConfig | undefined; + await mutateOpenClawConfig((config) => { const agents = (config.agents && typeof config.agents === 'object' ? { ...(config.agents as Record) } : {}) as Record; @@ -220,7 +225,7 @@ export async function setImageGenerationConfig( primary: next.primary, fallbacks: [...new Set(next.fallbacks.map((ref) => ref.trim()).filter(Boolean))], timeoutMs: next.timeoutMs, - }); + }, defaults.imageGenerationModel); if (writeValue) { defaults.imageGenerationModel = writeValue; @@ -234,10 +239,9 @@ export async function setImageGenerationConfig( agents.defaults = defaults; config.agents = agents; - await writeOpenClawConfig(config); - - return readImageGenerationConfig(); + savedConfig = parseImageGenerationModelConfig(defaults.imageGenerationModel); }); + return savedConfig!; } async function buildAgentAuthRows( @@ -316,10 +320,10 @@ function resolveOpenAiImageRelayModelId( } export async function getImageGenerationSettingsSnapshot(): Promise { - const config = await readImageGenerationConfig(); - const snapshot = await listAgentsSnapshot(); const openclawConfig = await readOpenClawConfig(); const defaults = getAgentsDefaults(openclawConfig); + const config = parseImageGenerationModelConfig(defaults?.imageGenerationModel); + const snapshot = await listAgentsSnapshotFromConfig(openclawConfig); const autoProviderFallback = defaults?.mediaGenerationAutoProviderFallback !== false; const providerKey = config.primary ? parseProviderFromModelRef(config.primary) : null; @@ -348,6 +352,12 @@ export async function applyOpenAiImageRelaySettings(params: { apiKey?: string; model?: string | null; }): Promise { + if (params.enabled) { + const plugin = await ensureClawXOpenAiImagePluginInstalled(); + if (!plugin.installed) { + throw new Error(plugin.warning || 'Failed to install ClawX OpenAI Image plugin'); + } + } const imageModelIds: string[] = []; const explicitModel = params.model?.trim(); if (explicitModel) { @@ -364,14 +374,11 @@ export async function applyOpenAiImageRelaySettings(params: { apiKey: params.apiKey, imageModelIds, }); - if (params.enabled) { - ensureClawXOpenAiImagePluginInstalled(); - } } export async function listImageGenerationProvidersFromRuntime(): Promise { const cfg = await readOpenClawConfig(); - const snapshot = await listAgentsSnapshot(); + const snapshot = await listAgentsSnapshotFromConfig(cfg); const rows = await listImageGenerationProvidersInProcess({ config: cfg, isProviderConfigured: (providerId) => isImageProviderAuthenticated(providerId, snapshot.defaultAgentId), diff --git a/electron/utils/openclaw-proxy.ts b/electron/utils/openclaw-proxy.ts index c4b28357..28527017 100644 --- a/electron/utils/openclaw-proxy.ts +++ b/electron/utils/openclaw-proxy.ts @@ -1,7 +1,7 @@ -import { readOpenClawConfig, writeOpenClawConfig } from './channel-config'; +import { mutateOpenClawConfig } from '../gateway/config-delivery'; +import type { OpenClawConfig } from './channel-config'; import { resolveProxySettings, type ProxySettings } from './proxy'; import { logger } from './logger'; -import { withConfigLock } from './config-mutex'; interface SyncProxyOptions { /** @@ -19,23 +19,26 @@ export async function syncProxyConfigToOpenClaw( settings: ProxySettings, options: SyncProxyOptions = {}, ): Promise { - return withConfigLock(async () => { - const config = await readOpenClawConfig(); + const resolved = resolveProxySettings(settings); + const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false; + const nextProxy = settings.proxyEnabled + ? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy) + : ''; + const syncState: { result: 'unchanged' | 'preserved' | 'updated' } = { result: 'unchanged' }; + + await mutateOpenClawConfig((snapshot) => { + syncState.result = 'unchanged'; + const config = snapshot as OpenClawConfig; const telegramConfig = config.channels?.telegram; if (!telegramConfig) { return; } - const resolved = resolveProxySettings(settings); - const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false; - const nextProxy = settings.proxyEnabled - ? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy) - : ''; const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : ''; if (!settings.proxyEnabled && preserveExistingWhenDisabled && currentProxy) { - logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled'); + syncState.result = 'preserved'; return; } @@ -57,7 +60,12 @@ export async function syncProxyConfigToOpenClaw( delete config.channels.telegram.proxy; } - await writeOpenClawConfig(config); - logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`); + syncState.result = 'updated'; }); + + if (syncState.result === 'preserved') { + logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled'); + } else if (syncState.result === 'updated') { + logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`); + } } diff --git a/electron/utils/plugin-install.ts b/electron/utils/plugin-install.ts index 49ad3f11..127e61c6 100644 --- a/electron/utils/plugin-install.ts +++ b/electron/utils/plugin-install.ts @@ -19,6 +19,7 @@ import { removePluginInstallRecordsFromSqlite, ensureOpenClawStateDirExists, } from './plugin-install-index'; +import { mutateOpenClawConfig } from '../gateway/config-delivery'; function normalizeFsPathForWindows(filePath: string): string { if (process.platform !== 'win32') return filePath; @@ -264,8 +265,6 @@ const PLUGIN_NPM_NAMES: Record = { 'openclaw-weixin': '@tencent-weixin/openclaw-weixin', }; -const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); - /** * Channel plugins whose ClawX-managed mirrors need synchronized install * metadata. OpenClaw 2026.6+ reads these records from SQLite for trust checks; @@ -381,33 +380,30 @@ function pluginInstallRecordIds(pluginDirName: string): string[] { ].filter((value): value is string => Boolean(value)))]; } -function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): boolean { - if (!existsSync(fsPath(OPENCLAW_CONFIG_PATH))) return false; +async function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): Promise { + const removedIds = new Set(); + const changed = await mutateOpenClawConfig((config) => { + removedIds.clear(); + const plugins = config.plugins; + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return; + const pluginsRecord = plugins as Record; + const installs = pluginsRecord.installs; + if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return; - const raw = readFileSync(fsPath(OPENCLAW_CONFIG_PATH), 'utf-8'); - const config = JSON.parse(raw) as Record; - const plugins = config.plugins; - if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return false; - const pluginsRecord = plugins as Record; - const installs = pluginsRecord.installs; - if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return false; - - const installsRecord = installs as Record; - const removedIds = pluginIds.filter((pluginId) => Object.hasOwn(installsRecord, pluginId)); - if (removedIds.length === 0) return false; - for (const pluginId of removedIds) { - delete installsRecord[pluginId]; + const installsRecord = installs as Record; + for (const pluginId of pluginIds) { + if (!Object.hasOwn(installsRecord, pluginId)) continue; + delete installsRecord[pluginId]; + removedIds.add(pluginId); + } + if (removedIds.size > 0 && Object.keys(installsRecord).length === 0) { + delete pluginsRecord.installs; + } + }); + if (removedIds.size > 0) { + logger.info(`[plugin] Removed legacy config install metadata for: ${[...removedIds].join(', ')}`); } - if (Object.keys(installsRecord).length === 0) { - delete pluginsRecord.installs; - } - writeFileSync( - fsPath(OPENCLAW_CONFIG_PATH), - `${JSON.stringify(config, null, 2)}\n`, - 'utf-8', - ); - logger.info(`[plugin] Removed legacy config install metadata for: ${removedIds.join(', ')}`); - return true; + return changed; } function canonicalComparablePath(filePath: string): string { @@ -524,10 +520,10 @@ function persistTrustedOfficialPluginInstallRecordsToSqlite( * migration input, so remove that transient copy instead of recreating it. * Safe to call repeatedly; no-ops when metadata is already current. */ -export function syncTrustedOfficialPluginInstallRecord( +export async function syncTrustedOfficialPluginInstallRecord( pluginDirName: string, targetDir: string, -): boolean { +): Promise { const expected = buildTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); if (!expected) return false; @@ -544,7 +540,7 @@ export function syncTrustedOfficialPluginInstallRecord( let jsonChanged = false; try { ensureOpenClawStateDirExists(); - jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds); + jsonChanged = await removeLegacyPluginInstallMetadataFromConfig(recordIds); } catch (error) { // Keep the canonical SQLite repair available even if legacy config cleanup // cannot be completed in this pass. @@ -567,13 +563,13 @@ export function syncTrustedOfficialPluginInstallRecord( * run even when its extension directory is already missing: stale records are * themselves enough to fail OpenClaw's post-core payload smoke check. */ -export function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): boolean { +export async function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): Promise { const recordIds = pluginInstallRecordIds(pluginDirName); if (recordIds.length === 0) return false; let jsonChanged = false; try { - jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds); + jsonChanged = await removeLegacyPluginInstallMetadataFromConfig(recordIds); } catch (error) { logger.warn(`[plugin] Failed to remove stale config install metadata for ${pluginDirName}:`, error); } @@ -582,13 +578,13 @@ export function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): } /** Repair managed install metadata and host peer links for all mirrors on disk. */ -export function repairTrustedOfficialPluginInstallRecords(): void { +export async function repairTrustedOfficialPluginInstallRecords(): Promise { for (const pluginDirName of Object.keys(TRUSTED_OFFICIAL_EXTENSION_PLUGINS)) { const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName); if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) { continue; } - syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); + await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); } } @@ -727,11 +723,11 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, // ── Core install / upgrade logic ───────────────────────────────────────────── -export function ensurePluginInstalled( +export async function ensurePluginInstalled( pluginDirName: string, candidateSources: string[], pluginLabel: string, -): { installed: boolean; warning?: string } { +): Promise<{ installed: boolean; warning?: string }> { const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName); const targetManifest = join(targetDir, 'openclaw.plugin.json'); const targetPkgJson = join(targetDir, 'package.json'); @@ -741,13 +737,13 @@ export function ensurePluginInstalled( // If already installed, check whether an upgrade is available if (existsSync(fsPath(targetManifest))) { if (!sourceDir) { - syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); + await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); return { installed: true }; // no bundled source to compare, keep existing } const installedVersion = readPluginVersion(targetPkgJson); const sourceVersion = readPluginVersion(join(sourceDir, 'package.json')); if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) { - syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); + await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); return { installed: true }; // same version or unable to compare } // Version differs — fall through to overwrite install @@ -771,7 +767,7 @@ export function ensurePluginInstalled( return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` }; } fixupPluginManifest(targetDir); - syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); + await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`); return { installed: true }; } catch (error) { @@ -820,7 +816,7 @@ export function ensurePluginInstalled( copyPluginFromNodeModules(npmPkgPath, targetDir, npmName); fixupPluginManifest(targetDir); if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) { - syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); + await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); return { installed: true }; } } catch (err) { @@ -838,7 +834,7 @@ export function ensurePluginInstalled( ); } } else if (existsSync(fsPath(targetManifest))) { - syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); + await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir); return { installed: true }; // same version, already installed } } @@ -874,15 +870,15 @@ export function buildCandidateSources(pluginDirName: string): string[] { // ── Per-channel plugin helpers ─────────────────────────────────────────────── -export function ensureDingTalkPluginInstalled(): { installed: boolean; warning?: string } { +export function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk'); } -export function ensureWeComPluginInstalled(): { installed: boolean; warning?: string } { +export function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom'); } -export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } { +export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { return ensurePluginInstalled( 'feishu-openclaw-plugin', buildCandidateSources('feishu-openclaw-plugin'), @@ -892,23 +888,23 @@ export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: s -export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } { +export function ensureWeChatPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat'); } -export function ensureDiscordPluginInstalled(): { installed: boolean; warning?: string } { +export function ensureDiscordPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { return ensurePluginInstalled('discord', buildCandidateSources('discord'), 'Discord'); } -export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string } { +export function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQBot'); } -export function ensureWhatsAppPluginInstalled(): { installed: boolean; warning?: string } { +export function ensureWhatsAppPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { return ensurePluginInstalled('whatsapp', buildCandidateSources('whatsapp'), 'WhatsApp'); } -export function ensureClawXOpenAiImagePluginInstalled(): { installed: boolean; warning?: string } { +export function ensureClawXOpenAiImagePluginInstalled(): Promise<{ installed: boolean; warning?: string }> { return ensurePluginInstalled( 'clawx-openai-image', buildCandidateSources('clawx-openai-image'), @@ -941,7 +937,7 @@ const ALL_BUNDLED_PLUGINS = [ export async function ensureAllBundledPluginsInstalled(): Promise { for (const { fn, label } of ALL_BUNDLED_PLUGINS) { try { - const result = fn(); + const result = await fn(); if (result.warning) { logger.warn(`[plugin] ${label}: ${result.warning}`); } @@ -949,5 +945,5 @@ export async function ensureAllBundledPluginsInstalled(): Promise { logger.warn(`[plugin] Failed to install/upgrade ${label} plugin:`, error); } } - repairTrustedOfficialPluginInstallRecords(); + await repairTrustedOfficialPluginInstallRecords(); } diff --git a/electron/utils/skill-config.ts b/electron/utils/skill-config.ts index 4684b612..8f65c47a 100644 --- a/electron/utils/skill-config.ts +++ b/electron/utils/skill-config.ts @@ -1,21 +1,16 @@ /** * Skill Config Utilities - * Direct read/write access to skill configuration in ~/.openclaw/openclaw.json - * This bypasses the Gateway RPC for faster and more reliable config updates. - * - * All file I/O uses async fs/promises to avoid blocking the main thread. + * Skill configuration reads and coordinated mutations for openclaw.json. */ -import { readFile, writeFile, access, mkdir, readdir, rm } from 'fs/promises'; +import { readFile, writeFile, mkdir, readdir, rm } from 'fs/promises'; import { existsSync } from 'fs'; -import { constants } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { getOpenClawDir, getOpenClawResolvedDir, getResourcesDir } from './paths'; import { logger } from './logger'; import { cpAsyncSafe } from './plugin-install'; -import { withConfigLock } from './config-mutex'; +import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery'; -const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']); export interface SkillConfigUpdates { @@ -60,52 +55,35 @@ interface PreinstalledMarker { installedAt: string; } -async function fileExists(p: string): Promise { - try { await access(p, constants.F_OK); return true; } catch { return false; } -} - /** * Read the current OpenClaw config */ async function readConfig(): Promise { - if (!(await fileExists(OPENCLAW_CONFIG_PATH))) { - return {}; - } try { - const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8'); - return JSON.parse(raw); + return (await readOpenClawConfigSnapshot()).config as OpenClawConfig; } catch (err) { console.error('Failed to read openclaw config:', err); return {}; } } -/** - * Write the OpenClaw config - */ -async function writeConfig(config: OpenClawConfig): Promise { - const json = JSON.stringify(config, null, 2); - await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8'); -} - async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise { if (skillKeys.length === 0) { return; } - return withConfigLock(async () => { - const config = await readConfig(); - if (!config.skills) { - config.skills = {}; + await mutateOpenClawConfig((config) => { + const skillConfig = config as OpenClawConfig; + if (!skillConfig.skills) { + skillConfig.skills = {}; } - if (!config.skills.entries) { - config.skills.entries = {}; + if (!skillConfig.skills.entries) { + skillConfig.skills.entries = {}; } for (const skillKey of skillKeys) { - const entry = config.skills.entries[skillKey] || {}; + const entry = skillConfig.skills.entries[skillKey] || {}; entry.enabled = enabled; - config.skills.entries[skillKey] = entry; + skillConfig.skills.entries[skillKey] = entry; } - await writeConfig(config); }); } @@ -209,12 +187,10 @@ export async function updateSkillConfigs( updates: Array<{ skillKey: string } & SkillConfigUpdates>, ): Promise<{ success: boolean; error?: string }> { try { - return await withConfigLock(async () => { - const config = await readConfig(); - await applySkillConfigUpdates(config, updates); - await writeConfig(config); - return { success: true }; + await mutateOpenClawConfig(async (config) => { + await applySkillConfigUpdates(config as OpenClawConfig, updates); }); + return { success: true }; } catch (err) { console.error('Failed to update skill config:', err); return { success: false, error: String(err) }; @@ -227,25 +203,25 @@ export async function removeSkillConfig(skillKey: string): Promise<{ success: bo export async function removeSkillConfigs(skillKeys: string[]): Promise<{ success: boolean; removed: number; error?: string }> { try { - return await withConfigLock(async () => { - const config = await readConfig(); - const existingEntries = config.skills?.entries || {}; - const normalizedSkillKeys = skillKeys - .map((skillKey) => skillKey.trim()) - .filter(Boolean); - const removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length; + const normalizedSkillKeys = skillKeys + .map((skillKey) => skillKey.trim()) + .filter(Boolean); + let removed = 0; + await mutateOpenClawConfig(async (config) => { + const skillConfig = config as OpenClawConfig; + const existingEntries = skillConfig.skills?.entries || {}; + removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length; if (removed === 0) { - return { success: true, removed: 0 }; + return; } await applySkillConfigUpdates( - config, + skillConfig, normalizedSkillKeys.map((skillKey) => ({ skillKey, remove: true })), ); - await writeConfig(config); - return { success: true, removed }; }); + return { success: true, removed }; } catch (err) { console.error('Failed to remove skill configs:', err); return { success: false, removed: 0, error: String(err) }; diff --git a/electron/utils/wechat-login.ts b/electron/utils/wechat-login.ts index bfaff6e7..4e14a6e3 100644 --- a/electron/utils/wechat-login.ts +++ b/electron/utils/wechat-login.ts @@ -1,10 +1,10 @@ import { createRequire } from 'node:module'; import { randomUUID } from 'node:crypto'; import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { deflateSync } from 'node:zlib'; +import { readOpenClawConfigSnapshot } from '../gateway/config-delivery'; import { normalizeOpenClawAccountId } from './channel-alias'; import { resolveOpenClawRuntimeModulePath } from './runtime-package-resolution'; @@ -209,18 +209,9 @@ function isLoginFresh(login: ActiveLogin): boolean { return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS; } -function resolveConfigPath(): string { - const envPath = process.env.OPENCLAW_CONFIG?.trim(); - if (envPath) return envPath; - return join(OPENCLAW_DIR, 'openclaw.json'); -} - -function loadWeChatRouteTag(accountId?: string): string | undefined { +async function loadWeChatRouteTag(accountId?: string): Promise { try { - const configPath = resolveConfigPath(); - if (!existsSync(configPath)) return undefined; - const raw = readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(raw) as { + const parsed = (await readOpenClawConfigSnapshot()).config as { channels?: Record; @@ -246,7 +237,7 @@ async function fetchWeChatQrCode(apiBaseUrl: string, accountId?: string, botType const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`; const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base); const headers: Record = {}; - const routeTag = loadWeChatRouteTag(accountId); + const routeTag = await loadWeChatRouteTag(accountId); if (routeTag) { headers.SKRouteTag = routeTag; } @@ -265,7 +256,7 @@ async function pollWeChatQrStatus(apiBaseUrl: string, qrcode: string, accountId? const headers: Record = { 'iLink-App-ClientVersion': '1', }; - const routeTag = loadWeChatRouteTag(accountId); + const routeTag = await loadWeChatRouteTag(accountId); if (routeTag) { headers.SKRouteTag = routeTag; } diff --git a/harness/reference/acp-chat.md b/harness/reference/acp-chat.md index 66fb6379..2011d434 100644 --- a/harness/reference/acp-chat.md +++ b/harness/reference/acp-chat.md @@ -1,6 +1,6 @@ # ACP Chat Architecture And Timeline -Status: current architecture reference, reviewed 2026-07-15. +Status: current architecture reference, reviewed 2026-07-27. Related scenario: `acp-chat-experience` @@ -19,7 +19,7 @@ Chat UI -> host-api -> Main ACP service -> openclaw acp session/update -> Main routing envelope -> Renderer reducer -> timeline -> React ``` -Gateway remains responsible for non-Chat capabilities. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history. +Gateway remains responsible for non-Chat capabilities. Renderer Chat does not call ordinary Gateway `chat.history` or `chat.send`, and Main has no Chat-history polling, coalescing, or backpressure specialization. Generic Gateway RPC requests retain Main-owned validation and timeout handling before direct `GatewayManager.rpc` dispatch. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history. ## Identity And Race Protection @@ -33,7 +33,7 @@ While `session/prompt` is pending, Main retains a bounded session-id routing con ACP `session/load` replay is the primary source of Chat history. ClawX does not persist an ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Full structured replay can restore tools and file activity; transcript-only fallback must not invent them. -OpenClaw emits replay through ordinary `session/update` notifications and completes the replay before `session/load` returns. Main collects those raw notifications for the active load generation and returns them with the load result instead of forwarding them incrementally. Renderer temporarily groups generation-matching host events that arrive during the IPC result handoff, then runs the normal reducer over the combined batch and publishes the resulting timeline in one state update. This is an in-flight transaction buffer only, not a history cache; after load, live updates continue through the normal host-event route. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters. +OpenClaw emits replay through ordinary `session/update` notifications and completes the replay before `session/load` returns. Main collects those raw notifications for the active load generation and returns them with the load result instead of forwarding them incrementally. Renderer temporarily groups generation-matching host events that arrive during the IPC result handoff, then runs the normal reducer over the combined batch and publishes the resulting timeline in one state update. This is an in-flight transaction buffer only, not a history cache; after load, each live update continues through the normal host-event route and is applied immediately without a Renderer batching timer. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters. There are exactly two approved transcript-derived content supplements. ClawX may recover asynchronous image-generation completions with proven `image_generate` context, and it may recover explicit line-leading assistant `MEDIA:` attachment directives omitted by OpenClaw ACP. Both are bounded, marked, memory-only projections. Separately, Main may extract metadata-only whole-turn timing because ACP replay omits original timestamps. Renderer can attach that timing only to an unambiguously matched ACP turn; it cannot reconstruct ordinary assistant text, thoughts, tool cards, plans, permissions, file activity, or missing turns. See `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions` for the content compatibility grammar and timing boundary. @@ -88,7 +88,7 @@ Available attachment cards contain a primary semantic action with keyboard activ ## Chat Behaviors -- The primary Chat view does not render the legacy Execution Graph. +- The primary Chat view renders process activity directly in the ordered ACP timeline. - A recoverable initial `reply was never sent` load failure may leave an empty new-chat page usable; prompt failures remain visible. - The working indicator follows the same sending state as the Stop action and supports reduced motion. - The question directory is derived only from active user message segments. Duplicate text remains separate, titles use the first non-empty Markdown part, and textless entries use a localized fallback. Fewer than two questions disables navigation. When open, the directory floats above the conversation without changing the chat column width. Selection scrolls smoothly to the current-snapshot anchor; a missing anchor is a safe no-op. The UI caps the directory at 300 recent entries and reports the hidden count when older entries are omitted. diff --git a/harness/reference/electron-rendering-performance.md b/harness/reference/electron-rendering-performance.md new file mode 100644 index 00000000..33a7d2f2 --- /dev/null +++ b/harness/reference/electron-rendering-performance.md @@ -0,0 +1,29 @@ +# Electron Rendering Performance + +Status: hardware-acceleration policy and interaction profile baselined 2026-08-01. + +Related scenarios: `acp-chat-experience`, `chat-workspace-and-navigation` + +Related rule: `electron-rendering-performance` + +Related task: `restore-hardware-accelerated-rendering` + +## Runtime Policy + +ClawX leaves Electron and Chromium hardware acceleration enabled by default. Main must not call `app.disableHardwareAcceleration()` or append a global `disable-gpu` switch. Chromium owns driver detection and fallback; users with a broken driver may still launch ClawX with Chromium's native `--disable-gpu` switch. + +Headless Linux and virtualized CI may report software compositing because no usable GPU is present. Tests must distinguish that environment fallback from an application-owned global disable policy. Desktop GPU assertions therefore run only where the test environment provides a real desktop GPU. + +## Diagnostic Contract + +`pnpm run perf:chat` covers both high-frequency ACP streaming and idle interaction with a rich static Markdown document. The interaction workload records sidebar-collapse and vertical-scroll frame intervals, Renderer performance metrics, DOM size, GPU feature status, and Renderer/Main CPU profiles. It uses generated content and writes only ignored Playwright artifacts. + +For a reported desktop regression, first reproduce with the user's real conversation and record `app.isHardwareAccelerationEnabled()` plus `app.getGPUFeatureStatus()` after `gpu-info-update`. Compare repeated runs on the same machine. Main CPU profiles do not include browser/GPU process rasterization or compositing, so a profile dominated by Chromium `(program)` time must be interpreted together with frame pacing and GPU status rather than as unexplained React work. + +Do not add machine-independent frame-time gates. Preserve semantic assertions, generated workload shape, and artifact schemas; compare repeated local or controlled-run medians when reviewing rendering changes. + +## Validation Anchors + +- Main policy: `electron/main/index.ts` and `tests/unit/main-hardware-acceleration.test.ts`. +- Desktop runtime behavior: `tests/e2e/hardware-acceleration.spec.ts`. +- Streaming and interaction profiles: `tests/e2e/renderer-performance.spec.ts` through `pnpm run perf:chat`. diff --git a/harness/reference/markdown-rendering.md b/harness/reference/markdown-rendering.md new file mode 100644 index 00000000..a511a2ff --- /dev/null +++ b/harness/reference/markdown-rendering.md @@ -0,0 +1,80 @@ +# Markdown Rendering + +Status: migration contract baselined 2026-08-01; implementation is validated by the related task. + +Related scenarios: `acp-chat-experience`, `chat-workspace-and-navigation` + +Related rule: `markdown-rendering-safety-and-performance` + +Related task: `replace-markdown-renderer-with-streamdown` + +## Rendering Ownership + +ClawX has two application Markdown surfaces with distinct update behavior and one shared renderer configuration: + +| Surface | Mode | Content | +| --- | --- | --- | +| ACP Chat | `streaming` | Assistant message and process Markdown parts | +| Markdown file preview | `static` | Authorized local Markdown file content | + +User messages remain literal React text and tool output remains preformatted. Neither enters Streamdown. The migration is presentation-only: ACP transport, event ordering, timeline reduction, store cadence, history, and Renderer/Main boundaries do not change. + +The shared plugin, rehype, component, animation, controls, and link-safety values remain module-scoped. Stable references allow Streamdown to retain completed block output instead of invalidating memoized blocks on each chunk. + +## Plugin Contract + +Exactly these optional capabilities are enabled: + +- `@streamdown/code` for Shiki-backed fenced-code highlighting. +- `@streamdown/math` for KaTeX, configured with `singleDollarTextMath: true`. +- `@streamdown/cjk` for CJK-aware autolink and punctuation boundaries. + +`@streamdown/mermaid` is not a direct dependency and is not configured. A `mermaid` fence remains an ordinary highlighted code block and never becomes a diagram, SVG, or interactive Mermaid container. + +KaTeX remains a direct dependency because the math plugin requires its CSS. The application imports `katex/dist/katex.min.css` exactly once. It also imports `streamdown/styles.css` exactly once so the selected animation keyframes and data-attribute styles exist. Tailwind scans Streamdown and each installed plugin distribution, but no Mermaid distribution path. + +## Content Safety + +Streamdown does not expand the authority of generated content: + +- User text and tool output remain literal outside the Markdown renderer. +- The shared rehype list retains Streamdown sanitization and hardening but omits raw-HTML parsing. Source HTML such as `` remains visible text and does not create an element. +- Links render through `BrowserLink`, which has no interactive anchor role or navigation. Streamdown link-safety UI is disabled because links are already inert. +- ACP Markdown images continue through `isSafeAcpImageSource`; an unapproved source does not become an image request. +- Table, Mermaid, code download, and line-number controls are disabled. Fenced code alone exposes Streamdown's copy control with its label supplied through `react-i18next`; the control remains disabled while a response is streaming. + +Static preview keeps `remark-frontmatter` for YAML (`---`) and TOML (`+++`) frontmatter. Parsed frontmatter is omitted from visible output. There is no custom frontmatter splitter, metadata card, or metadata `
`.
+
+## Streaming And Animation
+
+ACP Chat repairs incomplete Markdown while the response is active, but animation state is narrower than transport state. The Renderer derives active segment IDs from the open ACP assistant message segments only while send or cancel is active. A Markdown part receives `isAnimating`, word animation, and `caret="circle"` only when all of these conditions hold:
+
+- Its assistant segment is currently open.
+- It is the segment's final part.
+- Its part kind is Markdown.
+
+Earlier parts, completed segments, thoughts, user messages, and tool output never animate. The animation is word-level `fadeIn` with `duration: 140` and `stagger: 0`; character-level animation is forbidden. Previously completed words and blocks must remain stable as later chunks arrive, and the caret disappears when the send settles.
+
+## Presentation Contract
+
+Chat keeps the assistant-without-bubble layout and ClawX's established prose rhythm. Scoped Streamdown selectors restore heading and horizontal-rule margins over Streamdown's root spacing utility, compact ordered, unordered, and task-list items, and remove table wrapper borders while retaining the cell grid. Fenced code preserves Shiki's source-row spans as block lines, soft-wraps long lines, uses a compact right-aligned language header with vertically centered actions, and exposes copy without download; file preview keeps its preview-specific headings and inline code. Styling uses existing ClawX surfaces, text colors, dark-mode variants, and other design tokens; Streamdown defaults must not leak broad global changes into unrelated prose.
+
+The supported math contract includes `$...$`, `$$...$$`, `\(...\)`, and `\[...\]`. CJK tests anchor punctuation exclusion from autolinks. Code tests wait for Shiki token output rather than assuming highlighting is synchronous.
+
+## Performance Baseline And Review
+
+`pnpm run perf:chat` builds the production Renderer and executes a deterministic 80-turn history plus 300 streaming chunks. Before renderer changes and after migration, run it three times on the same machine and retain each generated `renderer-benchmark.json`, `renderer.cpuprofile`, and `main.cpuprofile` under ignored `test-results/` paths.
+
+Compare before/after medians for elapsed time, Renderer TaskDuration, ScriptDuration, layout and style duration, long-task count and duration, and sampled Markdown/React CPU stacks. Median TaskDuration and ScriptDuration must each stay within 10 percent of baseline. At least one of median ScriptDuration or sampled Markdown/render CPU time must improve by 10 percent or more. A miss requires profiling animation, Shiki, and last-block costs rather than weakening the threshold. Absolute machine timings are evidence for the local comparison, not automated cross-machine gates.
+
+Build with `pnpm exec vite build --sourcemap` and inspect chunk sizes and source maps. Streamdown and Shiki are expected costs. The review must confirm no direct Mermaid plugin and no unexpected eager Mermaid renderer chunk. Dormant code retained by Streamdown core is measured and documented rather than described as Mermaid UI support.
+
+## Validation Anchors
+
+Shared configuration is anchored by `src/components/markdown/streamdown-config.ts` and `tests/unit/streamdown-config.test.tsx`.
+
+Static preview behavior is anchored by `src/components/file-preview/MarkdownPreview.tsx`, `tests/unit/markdown-preview.test.tsx`, `tests/unit/file-preview-body.test.tsx`, and `tests/e2e/markdown-file-preview.spec.ts`.
+
+Streaming state and rendering are anchored by `src/pages/Chat/AcpTimeline.tsx`, `src/pages/Chat/AcpAssistantTurn.tsx`, `src/pages/Chat/AcpMessageSegment.tsx`, `tests/unit/acp-chat-components.test.tsx`, and `tests/e2e/chat-streamdown-rendering.spec.ts`.
+
+Existing soft-wrap, KaTeX, plain-assistant, and table-theme behavior remains anchored by `tests/e2e/chat-code-block-wrap.spec.ts`, `tests/e2e/chat-latex-rendering.spec.ts`, `tests/e2e/chat-assistant-markdown-plain.spec.ts`, and `tests/e2e/chat-table-header-light.spec.ts`. Performance evidence is produced by `tests/e2e/renderer-performance.spec.ts` through `pnpm run perf:chat`.
diff --git a/harness/reference/openclaw-config-delivery.md b/harness/reference/openclaw-config-delivery.md
new file mode 100644
index 00000000..cd01b708
--- /dev/null
+++ b/harness/reference/openclaw-config-delivery.md
@@ -0,0 +1,21 @@
+# OpenClaw Config Delivery
+
+ClawX bundles OpenClaw 2026.7.1. OpenClaw owns the field-level decision between a no-op snapshot update, hot application, subsystem restart, and in-process Gateway restart.
+
+Provider, Agent, Channel, skill, proxy, image-generation, and plugin-install helpers express config changes as mutators. One Main-owned coordinator owns selection of the authoritative baseline and the commit:
+
+1. If Gateway is running, call `config.get` and require its runtime-shaped `config` object and `hash`. The coordinator accepts `raw` only as a compatibility fallback for older responses.
+2. Clone the runtime-shaped config, apply the mutator, and call `config.set` with the serialized result and `baseHash: hash`. Using source-shaped `raw` as the preferred baseline can misalign redacted secret paths with OpenClaw's runtime-shaped restore baseline.
+3. Retry one base-hash conflict from a fresh `config.get`; fail other RPC errors without writing around the running Gateway.
+4. Treat success as converged and do not send `SIGUSR1` or replace the process.
+5. If Gateway is stopped or starting, apply the same mutator to `resolveOpenClawConfigPath()` under the shared config lock and do not start the Gateway.
+
+This is not a write-then-notify design. No provider, Agent, Channel, skill, proxy, image-generation, or plugin-install helper may write the active config independently. The coordinator prevents a locally read stale snapshot from overwriting concurrent Gateway or CLI config changes.
+
+Gateway WebSocket tracing must redact the complete serialized `raw` payload for `config.set`, `config.patch`, and `config.apply`; key-based structural redaction cannot inspect secrets embedded inside that string.
+
+Coordinator-backed reads follow the same authority rule: prefer the runtime-shaped `config.get.config` object while Gateway is running and use JSON5 file parsing while it is not. Compound views derive all config-backed fields from one snapshot.
+
+OpenClaw 2026.7.1 keeps auth-profile SQLite snapshots in memory. After a completed auth-store write batch, ClawX calls `secrets.reload` once when Gateway is running. `config.set` does not replace this refresh. Agent `models.json` needs no explicit RPC because OpenClaw re-reads it when its file fingerprint changes.
+
+Full ClawX process replacement remains necessary after a successful coordinator commit when values are injected only at process creation, including proxy environment changes, or for explicit manual lifecycle and health/crash recovery. OpenClaw config categories must not be duplicated as a ClawX restart whitelist.
diff --git a/harness/reference/sidebar-session-attention.md b/harness/reference/sidebar-session-attention.md
index 4bd96f3e..26c27f9d 100644
--- a/harness/reference/sidebar-session-attention.md
+++ b/harness/reference/sidebar-session-attention.md
@@ -182,6 +182,6 @@ Do not begin this migration merely because a type exists in an unbundled upstrea
 
 Primary implementation anchors are `shared/chat/types.ts`, `src/stores/gateway.ts`, `src/stores/chat.ts`, `src/stores/chat/session-catalog.ts`, `src/stores/chat/session-status.ts`, `src/stores/chat/session-label-hydration.ts`, `src/stores/session-attention.ts`, `src/components/layout/Sidebar.tsx`, and `src/pages/Chat/index.tsx`.
 
-Focused unit anchors are `tests/unit/session-status.test.ts`, `tests/unit/session-catalog.test.ts`, `tests/unit/session-attention.test.ts`, `tests/unit/session-label-hydration.test.ts`, `tests/unit/gateway-events.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/chat-store-history-retry.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/i18n-locale-parity.test.ts`, and `tests/unit/harness-specs.test.ts`. End-to-end presentation and navigation are covered by `tests/e2e/chat-sidebar-session-attention.spec.ts`.
+Focused unit anchors are `tests/unit/session-status.test.ts`, `tests/unit/session-catalog.test.ts`, `tests/unit/session-attention.test.ts`, `tests/unit/session-label-hydration.test.ts`, `tests/unit/gateway-events.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/chat-session-management.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/i18n-locale-parity.test.ts`, and `tests/unit/harness-specs.test.ts`. End-to-end presentation and navigation are covered by `tests/e2e/chat-sidebar-session-attention.spec.ts`.
 
 Communication changes require the task's Harness validation, communication replay/compare, typecheck, lint, Vite build, targeted unit tests, and Electron E2E test.
diff --git a/harness/specs/rules/acp-chat-state-and-history.md b/harness/specs/rules/acp-chat-state-and-history.md
index a23bc99f..d109a1b1 100644
--- a/harness/specs/rules/acp-chat-state-and-history.md
+++ b/harness/specs/rules/acp-chat-state-and-history.md
@@ -8,7 +8,7 @@ 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 ordinary live prompt updates continue through host events. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
+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.
 
 ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
 
diff --git a/harness/specs/rules/electron-rendering-performance.md b/harness/specs/rules/electron-rendering-performance.md
new file mode 100644
index 00000000..b4bd3646
--- /dev/null
+++ b/harness/specs/rules/electron-rendering-performance.md
@@ -0,0 +1,19 @@
+---
+id: electron-rendering-performance
+title: Electron Rendering Performance
+type: ai-coding-rule
+appliesTo:
+  - acp-chat-experience
+  - chat-workspace-and-navigation
+requiredProfiles:
+  - fast
+  - e2e
+---
+
+Leave Electron hardware acceleration enabled by default. Do not call `app.disableHardwareAcceleration()` or globally append `disable-gpu`; Chromium must retain driver detection and its native `--disable-gpu` troubleshooting fallback. Treat software compositing reported by headless or GPU-less CI as an environment result, not a reason to force every desktop renderer onto software rasterization.
+
+Rendering performance investigations must combine frame pacing, Renderer metrics/profile data, and `app.getGPUFeatureStatus()` captured after `gpu-info-update`. Main CPU profiles do not cover browser/GPU process rasterization. Do not attribute Chromium `(program)` samples to React without an isolated variable that changes the result.
+
+Keep `pnpm run perf:chat` coverage for both ACP streaming and rich static Markdown interaction. The interaction workload must exercise the production sidebar width animation and vertical Chat scroll path, record generated-only artifacts, and avoid hardware-independent timing gates. Compare repeated runs on the same machine while preserving semantic E2E assertions.
+
+The full runtime policy and validation anchors are recorded in `harness/reference/electron-rendering-performance.md`.
diff --git a/harness/specs/rules/markdown-rendering-safety-and-performance.md b/harness/specs/rules/markdown-rendering-safety-and-performance.md
new file mode 100644
index 00000000..7ff6f097
--- /dev/null
+++ b/harness/specs/rules/markdown-rendering-safety-and-performance.md
@@ -0,0 +1,23 @@
+---
+id: markdown-rendering-safety-and-performance
+title: Markdown Rendering Safety And Performance
+type: ai-coding-rule
+appliesTo:
+  - acp-chat-experience
+  - chat-workspace-and-navigation
+requiredProfiles:
+  - fast
+  - e2e
+---
+
+Use one module-scoped Streamdown configuration for application Markdown. ACP assistant and process Markdown uses streaming mode with incomplete-Markdown repair; Markdown file preview uses static mode. User messages and tool output remain literal and must not enter Streamdown. Enable only the code, math, and CJK plugins. Keep single-dollar math enabled, retain the direct KaTeX dependency and one application KaTeX stylesheet import, and import Streamdown styles once. Do not install or configure the Mermaid plugin; Mermaid fences remain code.
+
+Preserve the existing content boundary. Build the rehype list from Streamdown defaults without raw-HTML parsing while retaining sanitization and hardening, so raw HTML is visible literal text and never becomes active DOM. Render links through inert `BrowserLink` and disable Streamdown link-safety UI because no anchor remains interactive. Markdown images must continue through `isSafeAcpImageSource`. Enable only the localized code-copy control; disable table, Mermaid, code-download, and line-number controls. Parse YAML and TOML frontmatter in static preview and omit it from output; do not restore the custom splitter or metadata card.
+
+Keep plugin arrays, component maps, animation options, and security options at module scope so reference churn does not invalidate block memoization. Only the open assistant message segment's final Markdown part may set animation or caret props. Use word-level `fadeIn` with duration 140, stagger 0, and a circle caret; never animate by character. Completed segments, user messages, thoughts, earlier parts, and inactive sends must remain stable and must not acquire or restart animation.
+
+Preserve ClawX design tokens, assistant-without-bubble layout, prose block spacing, compact lists, the cell-only table grid and themes, source-line-preserving soft-wrapped code with a compact right-aligned language header, vertically centered copy action, and existing inert-link and image styling. Add Electron E2E coverage for streaming Chat and static preview. Tests must cover incomplete Markdown, highlighted and copyable multiline code, all supported math delimiters, CJK punctuation, Mermaid-as-code, literal raw HTML, inert links, safe images, literal user and tool output, frontmatter omission, active-part-only animation, completed-block stability, and the existing visual contracts.
+
+Capture three successful 80-turn and 300-chunk `pnpm run perf:chat` profiles before and after renderer changes on the same machine, retaining ignored Renderer metrics plus Renderer/Main CPU profiles. Compare three-run medians for elapsed time, Renderer TaskDuration, ScriptDuration, layout duration, long-task count and duration, and sampled Markdown/React stacks. TaskDuration and ScriptDuration may each regress by at most 10 percent; median ScriptDuration or sampled Markdown/render CPU time must improve by at least 10 percent. Inspect a production sourcemap build for Streamdown, Shiki, and unexpected Mermaid cost. Do not replace these relative checks with machine-specific automated timing gates.
+
+The complete rationale, ownership, safety policy, and validation anchors are recorded in `harness/reference/markdown-rendering.md`.
diff --git a/harness/specs/rules/openclaw-config-delivery.md b/harness/specs/rules/openclaw-config-delivery.md
new file mode 100644
index 00000000..74c56377
--- /dev/null
+++ b/harness/specs/rules/openclaw-config-delivery.md
@@ -0,0 +1,27 @@
+---
+id: openclaw-config-delivery
+title: OpenClaw Config Delivery
+type: ai-coding-rule
+appliesTo:
+  - gateway-backend-communication
+requiredProfiles:
+  - comms
+references:
+  - harness/reference/openclaw-config-delivery.md
+---
+
+ClawX must defer runtime config planning to the bundled OpenClaw Gateway.
+
+The Main-owned config coordinator must own the entire read-modify-write transaction. Production helpers must not write the active OpenClaw config and then notify another layer afterward.
+
+When the Gateway is running, the coordinator prefers the runtime-shaped `config.get.config` object as the mutation baseline, applies the caller's mutator, and commits through `config.set` with the returned `hash` as `baseHash`. Source-shaped `raw` is only a compatibility fallback because its redacted secret paths may not align with OpenClaw's write-side runtime snapshot. A successful mutation must not be followed by `SIGUSR1` or a ClawX process restart. Base-hash conflicts retry once from a new snapshot; other RPC failures fail closed instead of performing an out-of-band file write.
+
+Coordinator mutators are replayable transformations. They must not perform filesystem writes, SQLite writes, settings writes, lifecycle actions, or other non-idempotent external effects; preload required external inputs before entering the mutator and perform follow-up effects only after a successful commit.
+
+Gateway WebSocket traces must replace serialized `raw` config-write payloads with a redacted marker. They must not log credentials introduced by a mutator.
+
+When the Gateway is stopped or starting, the same coordinator mutates the resolved config file under the shared config lock. It must not start the Gateway solely to apply a config mutation.
+
+ClawX may replace the Gateway process for process-launch environment or argument changes, explicit user restart, application lifecycle, health/crash recovery, or a failed config-delivery fallback. Provider, Agent, Channel, binding, skill, model, and ordinary plugin-entry config changes must not carry a blanket ClawX restart policy when OpenClaw can plan them.
+
+All coordinator file fallback reads and writes must resolve the active config through `resolveOpenClawConfigPath()` so file delivery and Gateway RPC target the same config. No other production module may write that file.
diff --git a/harness/specs/scenarios/acp-chat-experience.md b/harness/specs/scenarios/acp-chat-experience.md
index d26676b1..da747a2d 100644
--- a/harness/specs/scenarios/acp-chat-experience.md
+++ b/harness/specs/scenarios/acp-chat-experience.md
@@ -12,6 +12,7 @@ ownedPaths:
   - electron/services/attachment-access.ts
   - electron/services/attachment-open-with.ts
   - electron/services/files-api.ts
+  - electron/main/index.ts
   - resources/scripts/attachment-open-with.ps1
   - src/lib/acp/**
   - src/lib/file-preview-client.ts
@@ -27,12 +28,20 @@ ownedPaths:
   - tests/e2e/chat-acp-inline-timeline.spec.ts
   - tests/e2e/chat-acp-attachments.spec.ts
   - tests/e2e/chat-run-state-events.spec.ts
+  - tests/e2e/chat-streamdown-rendering.spec.ts
+  - tests/e2e/chat-code-block-wrap.spec.ts
+  - tests/e2e/chat-latex-rendering.spec.ts
+  - tests/e2e/chat-assistant-markdown-plain.spec.ts
+  - tests/e2e/chat-table-header-light.spec.ts
+  - tests/e2e/hardware-acceleration.spec.ts
+  - tests/e2e/renderer-performance.spec.ts
 requiredProfiles:
   - fast
   - comms
 conditionalProfiles:
   e2e:
     - ACP timeline presentation changes
+    - Chat Markdown rendering, syntax highlighting, or animation changes
     - send, cancel, permission, media, or history behavior changes
 requiredRules:
   - renderer-main-boundary
@@ -44,6 +53,8 @@ requiredRules:
   - tool-derived-file-safety
   - office-preview-safety
   - ui-i18n-design-tokens
+  - markdown-rendering-safety-and-performance
+  - electron-rendering-performance
   - comms-regression
   - docs-sync
 ---
@@ -52,4 +63,4 @@ ACP Chat covers session load, prompt, cancel, permission, replay, timeline reduc
 
 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.
 
-The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, and `harness/reference/office-document-preview.md`.
+The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, Markdown rendering, Electron rendering performance policy, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, `harness/reference/office-document-preview.md`, `harness/reference/markdown-rendering.md`, and `harness/reference/electron-rendering-performance.md`.
diff --git a/harness/specs/scenarios/chat-workspace-and-navigation.md b/harness/specs/scenarios/chat-workspace-and-navigation.md
index 4d8355d4..69a912fa 100644
--- a/harness/specs/scenarios/chat-workspace-and-navigation.md
+++ b/harness/specs/scenarios/chat-workspace-and-navigation.md
@@ -6,11 +6,11 @@ ownedPaths:
   - shared/workspace.ts
   - shared/chat/session-title.ts
   - electron/services/sessions-api.ts
+  - electron/main/index.ts
   - src/lib/workspace-context.ts
   - src/hooks/use-workspace-availability.ts
   - src/stores/settings.ts
   - src/stores/chat.ts
-  - src/stores/chat/session-actions.ts
   - src/stores/chat/session-catalog.ts
   - src/stores/session-attention.ts
   - src/stores/chat/session-status.ts
@@ -19,6 +19,7 @@ ownedPaths:
   - src/components/file-preview/ArtifactPanel.tsx
   - src/components/file-preview/WorkspaceBrowserBody.tsx
   - src/components/file-preview/FilePreviewBody.tsx
+  - src/components/file-preview/MarkdownPreview.tsx
   - src/components/file-preview/DocxViewer.tsx
   - src/components/file-preview/PptxViewer.tsx
   - src/components/file-preview/build-preview-target.ts
@@ -30,6 +31,7 @@ ownedPaths:
   - src/pages/Chat/AcpTurnFileActivity.tsx
   - src/pages/Chat/AcpAttachmentPart.tsx
   - src/components/web-browser/**
+  - src/components/markdown/**
   - src/stores/artifact-panel.ts
   - src/components/layout/MainLayout.tsx
   - src/pages/Chat/ChatInput.tsx
@@ -49,9 +51,10 @@ ownedPaths:
   - tests/unit/i18n-locale-parity.test.ts
   - tests/unit/session-buckets.test.ts
   - tests/unit/generated-files.test.ts
-  - tests/unit/generated-files-panel.test.tsx
   - tests/unit/open-file-utils.test.ts
   - tests/unit/file-preview-body.test.tsx
+  - tests/unit/markdown-preview.test.tsx
+  - tests/unit/streamdown-config.test.tsx
   - tests/unit/workspace-browser-body.test.tsx
   - tests/unit/office-file-viewers.test.tsx
   - tests/unit/artifact-panel.test.tsx
@@ -64,11 +67,15 @@ ownedPaths:
   - tests/e2e/chat-acp-attachments.spec.ts
   - tests/e2e/chat-file-changes.spec.ts
   - tests/e2e/office-document-preview.spec.ts
+  - tests/e2e/markdown-file-preview.spec.ts
+  - tests/e2e/hardware-acceleration.spec.ts
+  - tests/e2e/renderer-performance.spec.ts
 requiredProfiles:
   - fast
 conditionalProfiles:
   e2e:
     - workspace selection, binding, sidebar, browser, or question navigation changes
+    - Markdown file-preview rendering or syntax highlighting changes
 requiredRules:
   - session-workspace-authority
   - renderer-main-boundary
@@ -76,11 +83,13 @@ requiredRules:
   - sidebar-session-attention-authority
   - office-preview-safety
   - web-browser-security-and-lifecycle
+  - markdown-rendering-safety-and-performance
+  - electron-rendering-performance
   - docs-sync
 ---
 
 This scenario covers inheriting the selected conversation's effective workspace when creating a new Chat; selecting persisted recent, known-session, or newly browsed workspaces while the new Chat remains unbound; validating workspace availability before ACP load; deriving a newly visible local-session title atomically from its first prompt; replacing matching synthetic UUID-date fallback titles with transcript prompts; recovering from deleted global or inherited workspace paths; marking unavailable non-default sidebar groups; permanently deleting their sessions after confirmation; binding workspaces through OpenClaw ACP cwd; targeting another agent without losing that agent's workspace or first prompt; restoring historical workspace context; renaming imported workspace display labels; navigating workspace-grouped sessions with busy, unread, and relative-time status; browsing the effective workspace; previewing authorized local HTML and supported Office documents under their documented safety boundaries; and jumping among user questions from an overlay that leaves the conversation width unchanged.
 
-Workspace file browsing keeps the store value `browser`; local HTML uses the existing `preview` tab and has no independent browser tab or toolbar. Current workspace resolution, ordering, title normalization, and file-browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`; the HTML guest contract is documented in `harness/reference/web-browser.md`.
+Workspace file browsing keeps the store value `browser`; local HTML uses the existing `preview` tab and has no independent browser tab or toolbar. Current workspace resolution, ordering, title normalization, and file-browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`; the HTML guest contract is documented in `harness/reference/web-browser.md`; static Markdown rendering and safety requirements are documented in `harness/reference/markdown-rendering.md`; desktop compositing and interaction profiling requirements are documented in `harness/reference/electron-rendering-performance.md`.
 
 DOCX and PPTX files are accepted as read-only inline previews only at or below the 20 MB compressed-input boundary. Scoped workspace and attachment references retain their authorized read route without naked-path fallback, while Workspace Browser retains its Host-validated absolute-path flow. PPTX visibility must preserve the single mounted PPTX viewer invariant across the kept-mounted Workspace and Preview surfaces. Workspace ownership remains in `harness/reference/chat-workspace-and-navigation.md`; the complete Office contract is `harness/reference/office-document-preview.md`.
diff --git a/harness/specs/scenarios/gateway-backend-communication.md b/harness/specs/scenarios/gateway-backend-communication.md
index cd20d98e..b287c39b 100644
--- a/harness/specs/scenarios/gateway-backend-communication.md
+++ b/harness/specs/scenarios/gateway-backend-communication.md
@@ -21,7 +21,7 @@ ownedPaths:
   - tests/unit/session-catalog.test.ts
   - tests/unit/gateway-events.test.ts
   - tests/unit/gateway-event-dispatch.test.ts
-  - tests/unit/chat-store-history-retry.test.ts
+  - tests/unit/chat-session-management.test.ts
   - tests/unit/chat-store-session-label-fetch.test.ts
   - tests/unit/session-label-hydration.test.ts
   - tests/e2e/chat-sidebar-session-attention.spec.ts
@@ -44,6 +44,7 @@ conditionalProfiles:
       - channels/agents/settings UI depends on new backend response shape
       - Web Browser guest, navigation, session, permission, or data policy changes
 requiredRules:
+  - openclaw-config-delivery
   - renderer-main-boundary
   - backend-communication-boundary
   - api-client-transport-policy
@@ -75,6 +76,8 @@ forbiddenPatterns:
 
 Gateway backend communication covers all ClawX paths that move data between the visual desktop UI and OpenClaw runtime/backend services.
 
+Coordinator-owned OpenClaw config mutations and their `config.get`/`config.set` transaction contract are documented in `harness/reference/openclaw-config-delivery.md`.
+
 Allowed flow:
 Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main typed host service or IPC handler -> Main-owned OpenClaw Gateway WebSocket -> runtime result -> store/UI.
 
@@ -82,6 +85,8 @@ Renderer code must not own transport selection, direct IPC channels, direct Gate
 
 Renderer code must not create direct Gateway WebSocket connections. Gateway frame diagnostics must be emitted by Main-process Gateway logging.
 
+Typed generic Gateway RPC requests are validated by `electron/services/gateway-api.ts` and delegated directly to `GatewayManager.rpc`, including an optional positive finite timeout. This path has no Renderer Chat history/send specialization, polling queue, coalescing, or backpressure layer. ACP `session/load`, `session/prompt`, and `session/cancel` own ordinary Chat history and composer behavior independently.
+
 Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark.
 
 Scheduled-task history is Main-owned backend data. Current OpenClaw versions must be queried through the Gateway `cron.runs` RPC; direct run-log file reads are allowed only as a compatibility fallback for older file-backed runtimes. When a run's bounded summary ends with OpenClaw's truncation ellipsis, Main may recover the complete final assistant reply from the run transcript identified by that `cron.runs` entry, but only when the transcript reply is longer and shares the entire summary prefix. When a cron base session has no ACP replay, Renderer may project that typed host result into a generation-scoped, in-memory historical ACP timeline, but must not replace or duplicate non-empty ACP replay.
diff --git a/harness/specs/scenarios/gateway-startup-diagnostics.md b/harness/specs/scenarios/gateway-startup-diagnostics.md
index 017a2c30..d97c5855 100644
--- a/harness/specs/scenarios/gateway-startup-diagnostics.md
+++ b/harness/specs/scenarios/gateway-startup-diagnostics.md
@@ -42,7 +42,7 @@ Treat these as the same incident family until proven otherwise:
 - `[gateway-startup] Slow managed Gateway startup detected`
 - `[gateway:rpc] doctor.memory.status failed`
 - `[gateway:rpc] doctor.memory.dreamDiary failed`
-- `chat.history unavailable during gateway startup`
+- `sessions.list unavailable during gateway startup`
 - Port `18789` is listening, but Gateway HTTP or WebSocket RPC does not return.
 
 Important distinction:
@@ -188,7 +188,7 @@ Expected mitigation:
 
 Symptoms:
 
-- Gateway handshake completes, but `system-presence`, `chat.history`, or `doctor.memory.*` times out during the first minutes.
+- Gateway handshake completes, but `system-presence`, `sessions.list`, or `doctor.memory.*` times out during the first minutes.
 - Logs mention cron repair, channel account checks, session lock cleanup, memory-core cron reconciliation, or active embedded/task runs.
 
 Expected behavior:
@@ -288,7 +288,7 @@ pnpm exec vitest run tests/unit/openclaw-auth.test.ts tests/unit/skills-symlink-
 pnpm run build:vite
 ```
 
-If the change touches Gateway send/receive, fallback, readiness, or chat history, also run:
+If the change touches Gateway send/receive, generic RPC dispatch, fallback, or readiness, also run:
 
 ```bash
 pnpm run comms:replay
diff --git a/harness/specs/tasks/acp-media-attachments.md b/harness/specs/tasks/acp-media-attachments.md
index 722d2fe3..14cafa5a 100644
--- a/harness/specs/tasks/acp-media-attachments.md
+++ b/harness/specs/tasks/acp-media-attachments.md
@@ -76,7 +76,7 @@ touchedAreas:
   - tests/unit/acp-host-contract.test.ts
   - tests/unit/acp-chat-service.test.ts
   - tests/unit/chat-acp-page.test.tsx
-  - tests/unit/chat-page-execution-graph.test.tsx
+  - tests/unit/chat-acp-inline-timeline.test.tsx
   - tests/unit/attachment-access.test.ts
   - tests/unit/files-api-workspace.test.ts
   - tests/unit/media-api.test.ts
@@ -98,7 +98,6 @@ touchedAreas:
   - tests/e2e/chat-acp-inline-timeline.spec.ts
   - tests/e2e/chat-assistant-markdown-plain.spec.ts
   - tests/e2e/chat-code-block-wrap.spec.ts
-  - tests/e2e/chat-history-startup-retry.spec.ts
   - tests/e2e/chat-latex-rendering.spec.ts
   - tests/e2e/chat-new-session-date.spec.ts
   - tests/e2e/chat-question-directory.spec.ts
@@ -106,7 +105,7 @@ touchedAreas:
   - tests/e2e/chat-scroll-pin-bottom.spec.ts
   - tests/e2e/chat-scroll-to-latest.spec.ts
   - tests/e2e/chat-table-header-light.spec.ts
-  - tests/e2e/chat-task-visualizer.spec.ts
+  - tests/e2e/chat-acp-process-timeline.spec.ts
   - tests/e2e/chat-workspace-context.spec.ts
   - tests/e2e/cron-run-live-status.spec.ts
   - tests/e2e/fixtures/electron.ts
diff --git a/harness/specs/tasks/acp-native-chat.md b/harness/specs/tasks/acp-native-chat.md
index a07e98a0..22b7946c 100644
--- a/harness/specs/tasks/acp-native-chat.md
+++ b/harness/specs/tasks/acp-native-chat.md
@@ -30,15 +30,14 @@ touchedAreas:
   - tests/unit/acp-*.test.tsx
   - tests/unit/chat-input.test.tsx
   - tests/unit/chat-acp-page.test.tsx
-  - tests/unit/chat-page-execution-graph.test.tsx
+  - tests/unit/chat-acp-inline-timeline.test.tsx
   - tests/unit/host-api-facade.test.ts
   - tests/unit/host-events.test.ts
   - tests/unit/host-services.test.ts
   - tests/unit/openclaw-cli.test.ts
-  - tests/unit/task-visualization.test.ts
   - tests/e2e/chat-acp-inline-timeline.spec.ts
   - tests/e2e/chat-run-state-events.spec.ts
-  - tests/e2e/chat-task-visualizer.spec.ts
+  - tests/e2e/chat-acp-process-timeline.spec.ts
   - README.md
   - README.zh-CN.md
   - README.ja-JP.md
@@ -46,7 +45,6 @@ expectedUserBehavior:
   - Opening a Chat session loads history through ACP session/load replay.
   - Sending a Chat prompt uses ACP session/prompt, shows an optimistic user segment, and coalesces it with the ACP user echo.
   - Thinking, tool calls, permission requests, plans, generated files, and generated images appear as inline timeline blocks in ACP event order.
-  - The old Execution Graph aggregation is not used for the ACP Chat path.
   - Renderer does not call Gateway HTTP or WebSocket endpoints directly.
   - Gateway-backed models, providers, plugins, skills, doctor, workspace, settings, and media configuration continue to work.
 requiredProfiles:
@@ -67,9 +65,9 @@ requiredRules:
 requiredTests:
   - pnpm run typecheck
   - pnpm exec vitest run tests/unit/acp-host-contract.test.ts tests/unit/acp-chat-service.test.ts tests/unit/acp-reducer.test.ts tests/unit/acp-chat-store.test.ts tests/unit/acp-chat-components.test.tsx tests/unit/chat-acp-page.test.tsx
-  - pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/openclaw-cli.test.ts tests/unit/host-services.test.ts tests/unit/chat-page-execution-graph.test.tsx tests/unit/task-visualization.test.ts
+  - pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/openclaw-cli.test.ts tests/unit/host-services.test.ts tests/unit/chat-acp-inline-timeline.test.tsx
   - pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts
-  - pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-task-visualizer.spec.ts
+  - pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-acp-process-timeline.spec.ts
   - pnpm run comms:replay
   - pnpm run comms:compare
 acceptance:
@@ -77,7 +75,7 @@ acceptance:
   - Main forwards ACP SessionNotification envelopes and permission request envelopes without translating text, thinking, tools, or media into legacy Chat events.
   - Renderer reduces ACP notifications into an in-memory ordered timeline.
   - No ClawX ACP replay ledger, Chat history cache, or reduced timeline persistence is introduced.
-  - The primary Chat page does not use gateway:chat-message or chat:runtime-event as ordinary Chat timeline sources; restricted image-generation compatibility evidence remains allowed.
+  - The primary Chat page uses ACP notifications and replay as its ordinary timeline sources; bounded image-generation compatibility evidence remains allowed.
   - Inline process blocks preserve ordering between assistant message segments.
 docs:
   required: true
diff --git a/harness/specs/tasks/add-chat-performance-diagnostics.md b/harness/specs/tasks/add-chat-performance-diagnostics.md
new file mode 100644
index 00000000..3736a023
--- /dev/null
+++ b/harness/specs/tasks/add-chat-performance-diagnostics.md
@@ -0,0 +1,63 @@
+---
+id: add-chat-performance-diagnostics
+title: Add chat performance diagnostics
+scenario: gateway-backend-communication
+taskType: runtime-bridge
+intent: Establish repeatable Renderer and Electron Main profiling for ACP chat streaming, then remove measured per-update work without changing OpenClaw.
+touchedAreas:
+  - harness/specs/tasks/add-chat-performance-diagnostics.md
+  - tests/e2e/renderer-performance.spec.ts
+  - tests/e2e/fixtures/electron.ts
+  - src/pages/Chat/**
+  - src/lib/acp/**
+  - src/stores/acp-chat-session.ts
+  - electron/services/acp-trace.ts
+  - electron/gateway/event-dispatch.ts
+  - electron/main/index.ts
+  - tests/unit/**
+  - package.json
+  - README.md
+  - README.zh-CN.md
+  - README.ja-JP.md
+  - AGENTS.md
+expectedUserBehavior:
+  - ACP chat output remains semantically identical while long streaming responses stay responsive.
+  - Developers can capture bounded Renderer and Main CPU profiles from a deterministic synthetic chat workload.
+  - Profiling uses isolated synthetic data and never modifies OpenClaw.
+requiredProfiles:
+  - fast
+  - comms
+requiredRules:
+  - renderer-main-boundary
+  - backend-communication-boundary
+  - host-events-fallback-policy
+  - diagnostics-trace-safety
+  - comms-regression
+  - docs-sync
+requiredTests:
+  - pnpm run perf:chat
+  - pnpm exec vitest run tests/unit/acp-chat-store.test.ts tests/unit/acp-trace.test.ts tests/unit/gateway-event-dispatch.test.ts
+  - pnpm run typecheck
+  - pnpm run build:vite
+  - pnpm run comms:replay
+  - pnpm run comms:compare
+acceptance:
+  - The performance command writes versioned Renderer metrics plus standard Renderer and Main CPU profile artifacts.
+  - The synthetic workload covers a populated ACP timeline and a growing live Markdown response.
+  - Performance artifacts contain only generated fixture content and remain outside source control.
+  - Each confirmed optimization has focused regression coverage and preserves ACP event ordering and rendered output.
+  - Renderer continues to receive backend events through the existing typed host event boundary.
+docs:
+  required: true
+---
+
+## Scope
+
+This task creates a deterministic profiling loop for Electron Main and Renderer, records a baseline, and applies only optimizations supported by those recordings.
+
+## Out of Scope
+
+- Changes to the OpenClaw source tree or bundled package.
+- Hardware-independent absolute timing gates.
+- Uploading CPU profiles or performance traces as product telemetry.
+- GPU-process or native-code profiling.
diff --git a/harness/specs/tasks/apply-acp-stream-updates-immediately.md b/harness/specs/tasks/apply-acp-stream-updates-immediately.md
new file mode 100644
index 00000000..e2fdc429
--- /dev/null
+++ b/harness/specs/tasks/apply-acp-stream-updates-immediately.md
@@ -0,0 +1,43 @@
+---
+id: apply-acp-stream-updates-immediately
+title: Apply live ACP stream updates immediately
+scenario: gateway-backend-communication
+taskType: runtime-bridge
+intent: Remove Renderer-side live ACP chunk batching so Streamdown receives each accepted host event without a 16 ms queue.
+touchedAreas:
+  - harness/specs/tasks/apply-acp-stream-updates-immediately.md
+  - harness/reference/acp-chat.md
+  - harness/specs/rules/acp-chat-state-and-history.md
+  - src/stores/acp-chat-session.ts
+  - tests/unit/acp-chat-store.test.ts
+  - tests/e2e/chat-streamdown-rendering.spec.ts
+expectedUserBehavior:
+  - Live assistant output advances as each ACP host event arrives instead of waiting for a Renderer batching timer.
+  - Stream ordering, stale-generation rejection, permission handling, and completed prompt behavior remain unchanged.
+requiredProfiles:
+  - fast
+  - comms
+requiredRules:
+  - renderer-main-boundary
+  - backend-communication-boundary
+  - host-events-fallback-policy
+  - acp-chat-state-and-history
+  - comms-regression
+requiredTests:
+  - pnpm exec vitest run tests/unit/acp-chat-store.test.ts
+  - pnpm exec playwright test tests/e2e/chat-streamdown-rendering.spec.ts
+  - pnpm run typecheck
+  - pnpm run comms:replay
+  - pnpm run comms:compare
+acceptance:
+  - Each accepted live ACP session update is applied synchronously through the existing typed host-event subscription.
+  - Two consecutive live ACP chunks produce two ordered Store notifications rather than one timer-batched notification.
+  - Historical session replay retains its existing generation-scoped reduction behavior.
+  - The Renderer introduces no replacement queue, timer, transport, or fallback path.
+docs:
+  required: false
+---
+
+## Scope
+
+This task changes only the Renderer Store update cadence for live ACP host events. It does not change ACP transport, Main-process routing, timeline reduction semantics, Streamdown configuration, or persisted history authority.
diff --git a/harness/specs/tasks/chat-tool-events-runtime-pipeline.md b/harness/specs/tasks/chat-tool-events-runtime-pipeline.md
deleted file mode 100644
index 2bd797de..00000000
--- a/harness/specs/tasks/chat-tool-events-runtime-pipeline.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-id: chat-tool-events-runtime-pipeline
-title: Make Chat runtime event-first with Main-owned Gateway communication
-scenario: gateway-backend-communication
-taskType: runtime-bridge
-intent: Move Chat send/history/control and streamed runtime events to a Main-owned pipeline, consume OpenClaw tool events as the active-run source of truth, simplify Execution Graph active rendering, and remove the legacy dual-track Chat store path.
-touchedAreas:
-  - harness/specs/tasks/chat-tool-events-runtime-pipeline.md
-  - electron/api/routes/gateway.ts
-  - electron/gateway/chat-runtime-events.ts
-  - electron/gateway/event-dispatch.ts
-  - electron/gateway/manager.ts
-  - electron/gateway/ws-client.ts
-  - electron/main/index.ts
-  - electron/main/ipc-handlers.ts
-  - electron/preload/index.ts
-  - shared/chat-runtime-events.ts
-  - src/lib/host-events.ts
-  - src/pages/Chat/index.tsx
-  - src/pages/Chat/image-generation-status.ts
-  - src/pages/Chat/task-visualization.ts
-  - src/stores/chat.ts
-  - src/stores/chat/**
-  - src/stores/chat/runtime-graph.ts
-  - src/stores/gateway.ts
-  - tests/e2e/chat-run-state-events.spec.ts
-  - tests/e2e/chat-task-visualizer.spec.ts
-  - tests/unit/chat-page-execution-graph.test.tsx
-  - tests/unit/chat-runtime-event-handlers.test.ts
-  - tests/unit/chat-store-history-retry.test.ts
-  - tests/unit/chat-store-session-label-fetch.test.ts
-  - tests/unit/gateway-event-dispatch.test.ts
-  - tests/unit/gateway-events.test.ts
-  - tests/unit/host-events.test.ts
-  - tests/unit/image-generation-status.test.ts
-  - tests/unit/task-visualization.test.ts
-expectedUserBehavior:
-  - Chat send/history/abort flows no longer depend on renderer direct Gateway WebSocket transport.
-  - Active chat runs stream tool lifecycle and related process updates through Main-owned runtime events.
-  - Execution Graph for the active run reflects streamed runtime events instead of inferring the live timeline from history polling.
-  - Final assistant reply continues to render as a normal chat bubble, while Execution Graph focuses on process steps.
-  - Tool-produced file artifacts continue to surface under the final assistant message.
-  - Historical sessions still reconstruct process graphs from transcript/message history as a fallback.
-requiredProfiles:
-  - fast
-  - comms
-requiredRules:
-  - renderer-main-boundary
-  - backend-communication-boundary
-  - api-client-transport-policy
-  - host-api-fallback-policy
-  - host-events-fallback-policy
-  - gateway-readiness-policy
-requiredTests:
-  - pnpm run typecheck
-  - pnpm run lint
-  - tests/unit/gateway-event-dispatch.test.ts
-  - tests/unit/chat-runtime-event-handlers.test.ts
-  - tests/e2e/chat-task-visualizer.spec.ts
-  - pnpm run comms:replay
-  - pnpm run comms:compare
-acceptance:
-  - Renderer Chat code uses Host API / Host events rather than renderer-owned Gateway transport.
-  - Main Gateway connection declares the capability needed to receive streamed tool events.
-  - Main normalizes OpenClaw chat/agent runtime events before forwarding them to the renderer.
-  - Active-run Execution Graph is driven by streamed runtime events and only updates existing steps by stable runtime identifiers.
-  - Default chat history polling is removed from the active-run happy path.
-  - Chat store no longer maintains separate live logic in both the monolithic store and an unused duplicate action path.
-docs:
-  required: false
----
diff --git a/harness/specs/tasks/chat-workspace-context.md b/harness/specs/tasks/chat-workspace-context.md
index 82e4b9d9..8e48914d 100644
--- a/harness/specs/tasks/chat-workspace-context.md
+++ b/harness/specs/tasks/chat-workspace-context.md
@@ -47,7 +47,7 @@ touchedAreas:
   - tests/unit/session-title.test.ts
   - tests/unit/host-services.test.ts
   - tests/unit/chat-store-session-label-fetch.test.ts
-  - tests/unit/chat-store-history-retry.test.ts
+  - tests/unit/chat-session-management.test.ts
   - tests/unit/sessions-api-workspace.test.ts
   - tests/unit/chat-acp-page.test.tsx
   - tests/unit/workspace-browser-body.test.tsx
@@ -79,7 +79,7 @@ requiredRules:
 requiredTests:
   - pnpm harness validate --spec harness/specs/tasks/chat-workspace-context.md
   - pnpm run typecheck
-  - pnpm exec vitest run tests/unit/workspace-context.test.ts tests/unit/session-title.test.ts tests/unit/host-services.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-store-history-retry.test.ts tests/unit/sidebar-session-buckets.test.ts tests/unit/sessions-api-workspace.test.ts tests/unit/session-buckets.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/workspace-browser-body.test.tsx
+  - pnpm exec vitest run tests/unit/workspace-context.test.ts tests/unit/session-title.test.ts tests/unit/host-services.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-session-management.test.ts tests/unit/sidebar-session-buckets.test.ts tests/unit/sessions-api-workspace.test.ts tests/unit/session-buckets.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/workspace-browser-body.test.tsx
   - pnpm run build:vite
   - pnpm exec playwright test tests/e2e/chat-workspace-context.spec.ts
   - pnpm run comms:replay
diff --git a/harness/specs/tasks/custom-provider-context-window-defaults.md b/harness/specs/tasks/custom-provider-context-window-defaults.md
index 7d8800ff..eb8b2b60 100644
--- a/harness/specs/tasks/custom-provider-context-window-defaults.md
+++ b/harness/specs/tasks/custom-provider-context-window-defaults.md
@@ -68,4 +68,3 @@ for the model. Try /reset (or /new) ...". Users also had no
 - Renderer UI for editing contextWindow per model.
 - Writing `maxTokens` for non-anthropic providers (changes request payloads).
 - Backfill for non-custom (registry/ollama) provider entries.
-- Image attachment compression in `chat:sendWithMedia`.
diff --git a/harness/specs/tasks/delete-unavailable-chat-workspace.md b/harness/specs/tasks/delete-unavailable-chat-workspace.md
index d9fc040f..b868a387 100644
--- a/harness/specs/tasks/delete-unavailable-chat-workspace.md
+++ b/harness/specs/tasks/delete-unavailable-chat-workspace.md
@@ -25,8 +25,8 @@ touchedAreas:
   - src/stores/settings.ts
   - tests/unit/use-workspace-availability.test.tsx
   - tests/unit/chat-acp-page.test.tsx
-  - tests/unit/chat-page-execution-graph.test.tsx
-  - tests/unit/chat-store-history-retry.test.ts
+  - tests/unit/chat-acp-inline-timeline.test.tsx
+  - tests/unit/chat-session-management.test.ts
   - tests/unit/settings-store.test.ts
   - tests/e2e/chat-workspace-context.spec.ts
 expectedUserBehavior:
@@ -51,7 +51,7 @@ requiredRules:
   - docs-sync
 requiredTests:
   - pnpm harness validate --spec harness/specs/tasks/delete-unavailable-chat-workspace.md
-  - pnpm exec vitest run tests/unit/use-workspace-availability.test.tsx tests/unit/chat-store-history-retry.test.ts tests/unit/settings-store.test.ts
+  - pnpm exec vitest run tests/unit/use-workspace-availability.test.tsx tests/unit/chat-session-management.test.ts tests/unit/settings-store.test.ts
   - pnpm exec playwright test tests/e2e/chat-workspace-context.spec.ts
   - pnpm run typecheck
   - pnpm run build:vite
diff --git a/harness/specs/tasks/fix-acp-sidebar-title-race.md b/harness/specs/tasks/fix-acp-sidebar-title-race.md
index e9e9482b..d91af9da 100644
--- a/harness/specs/tasks/fix-acp-sidebar-title-race.md
+++ b/harness/specs/tasks/fix-acp-sidebar-title-race.md
@@ -12,11 +12,9 @@ touchedAreas:
   - shared/chat/types.ts
   - src/pages/Chat/index.tsx
   - src/stores/chat.ts
-  - src/stores/chat/session-actions.ts
   - src/stores/chat/session-catalog.ts
   - tests/unit/chat-acp-page.test.tsx
   - tests/unit/chat-load-sessions-startup.test.ts
-  - tests/unit/chat-session-actions.test.ts
   - tests/unit/chat-store-session-label-fetch.test.ts
   - tests/unit/session-catalog.test.ts
   - tests/e2e/chat-new-session-date.spec.ts
@@ -32,7 +30,7 @@ requiredRules:
   - session-workspace-authority
   - sidebar-session-attention-authority
 requiredTests:
-  - pnpm exec vitest run tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-session-actions.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-acp-page.test.tsx
+  - pnpm exec vitest run tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-acp-page.test.tsx
   - pnpm run build:vite && pnpm exec playwright test tests/e2e/chat-new-session-date.spec.ts
   - pnpm run typecheck
 acceptance:
diff --git a/harness/specs/tasks/fix-chat-history-gateway-timeout.example.md b/harness/specs/tasks/fix-chat-history-gateway-timeout.example.md
deleted file mode 100644
index d626e942..00000000
--- a/harness/specs/tasks/fix-chat-history-gateway-timeout.example.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-id: fix-chat-history-gateway-timeout
-title: Fix chat history timeout through gateway backend communication
-scenario: gateway-backend-communication
-taskType: runtime-bridge
-intent: Adjust backend communication behavior for chat history loading.
-touchedAreas:
-  - src/lib/api-client.ts
-  - src/stores/chat/history-actions.ts
-expectedUserBehavior:
-  - Chat history loads through the existing host API boundary.
-  - Gateway timeout does not leave the visible chat in a stale state.
-requiredProfiles:
-  - fast
-  - comms
-requiredTests:
-  - tests/unit/chat-history-actions.test.ts
-acceptance:
-  - Renderer does not add direct IPC calls.
-  - Renderer does not fetch Gateway HTTP directly.
-  - Comms replay and compare pass.
-docs:
-  required: false
----
-
-Example task spec for gateway backend communication work. Copy this file to a task-specific name before starting an AI Coding change.
diff --git a/harness/specs/tasks/fix-chat-history-gateway-timeout.md b/harness/specs/tasks/fix-chat-history-gateway-timeout.md
deleted file mode 100644
index e8d89985..00000000
--- a/harness/specs/tasks/fix-chat-history-gateway-timeout.md
+++ /dev/null
@@ -1,62 +0,0 @@
----
-id: fix-chat-history-gateway-timeout
-title: Fix chat history timeout through gateway backend communication
-scenario: gateway-backend-communication
-taskType: runtime-bridge
-intent: Reduce startup chat.history contention so foreground history loads do not time out behind sidebar background hydration.
-touchedAreas:
-  - harness/specs/tasks/fix-chat-history-gateway-timeout.md
-  - electron/services/sessions-api.ts
-  - electron/gateway/rpc-backpressure.ts
-  - electron/main/ipc-handlers.ts
-  - src/components/layout/Sidebar.tsx
-  - src/pages/Chat/index.tsx
-  - src/pages/Chat/message-utils.ts
-  - src/stores/chat.ts
-  - src/stores/chat/history-actions.ts
-  - src/stores/chat/history-startup-retry.ts
-  - src/stores/chat/session-actions.ts
-  - src/stores/chat/session-label-hydration.ts
-  - src/stores/chat/store-api.ts
-  - src/stores/chat/types.ts
-  - tests/setup.ts
-  - tests/e2e/chat-history-startup-retry.spec.ts
-  - tests/unit/chat-store-history-retry.test.ts
-  - tests/unit/chat-store-session-label-fetch.test.ts
-  - tests/unit/gateway-rpc-backpressure.test.ts
-  - tests/unit/history-startup-retry.test.ts
-  - tests/unit/session-label-fetch.test.ts
-  - tests/unit/host-services.test.ts
-expectedUserBehavior:
-  - Foreground chat history loading is prioritized during gateway startup and restart.
-  - Sidebar/session label hydration does not compete with the first visible history load.
-  - Sidebar label hydration uses the existing host API boundary instead of heavy gateway chat.history fan-out.
-  - Renderer continues to use the existing host/store boundary.
-requiredProfiles:
-  - fast
-  - comms
-requiredRules:
-  - gateway-readiness-policy
-  - renderer-main-boundary
-  - backend-communication-boundary
-  - api-client-transport-policy
-requiredTests:
-  - pnpm run typecheck
-  - tests/e2e/chat-history-startup-retry.spec.ts
-  - tests/unit/chat-store-history-retry.test.ts
-  - tests/unit/history-startup-retry.test.ts
-  - tests/unit/chat-store-session-label-fetch.test.ts
-  - tests/unit/gateway-rpc-backpressure.test.ts
-  - tests/unit/session-label-fetch.test.ts
-  - tests/unit/host-services.test.ts
-acceptance:
-  - Renderer does not add direct IPC calls.
-  - Renderer does not fetch Gateway HTTP directly.
-  - Startup/restart no longer fans out sidebar label chat.history calls before the visible session history finishes loading.
-  - Sidebar label hydration no longer depends on gateway chat.history full-session scans.
-  - Foreground history uses a bounded startup RPC wait and falls back to local transcript reads instead of surfacing transient RPC timeout errors.
-  - Foreground startup history can show local transcript data while chat.history is pending, then replace it with Gateway history without disabling startup retry.
-  - Main-process chat.history RPCs are single-flighted/backpressured before reaching the Gateway.
-docs:
-  required: false
----
diff --git a/harness/specs/tasks/fix-chat-stuck-processing-tool-results.md b/harness/specs/tasks/fix-chat-stuck-processing-tool-results.md
deleted file mode 100644
index 760ff8e7..00000000
--- a/harness/specs/tasks/fix-chat-stuck-processing-tool-results.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-id: fix-chat-stuck-processing-tool-results
-title: Clear stale chat run state when Gateway reports the session is idle
-scenario: gateway-backend-communication
-taskType: runtime-bridge
-intent: Prevent the renderer from staying stuck on “Processing tool results…” when a terminal Gateway lifecycle event was missed but sessions.list reports the current session has no active run.
-touchedAreas:
-  - harness/specs/tasks/fix-chat-stuck-processing-tool-results.md
-  - package.json
-  - pnpm-lock.yaml
-  - src/stores/chat.ts
-  - src/stores/chat/session-actions.ts
-  - src/stores/chat/types.ts
-  - tests/unit/chat-session-actions.test.ts
-expectedUserBehavior:
-  - If Gateway reports the current session has status done or hasActiveRun=false after the user's send timestamp, the chat composer and tool-processing indicator return to idle.
-  - A fresh in-flight send is not prematurely cleared by stale sessions.list metadata from before the user message.
-  - Renderer continues to use the existing gateway:rpc Main-process boundary.
-requiredProfiles:
-  - fast
-  - comms
-requiredRules:
-  - gateway-readiness-policy
-  - renderer-main-boundary
-  - backend-communication-boundary
-  - api-client-transport-policy
-requiredTests:
-  - pnpm exec vitest run tests/unit/chat-session-actions.test.ts
-  - pnpm run typecheck
-acceptance:
-  - Renderer does not add direct IPC calls outside the existing api-client invocation path.
-  - Renderer does not fetch Gateway HTTP directly.
-  - sessions.list idle metadata reconciles stale sending/activeRunId/pendingFinal state for the current session.
-  - sessions.list metadata older than the current user send does not clear active state.
-docs:
-  required: false
----
diff --git a/harness/specs/tasks/fix-first-chat-no-response-fallback-poll.md b/harness/specs/tasks/fix-first-chat-no-response-fallback-poll.md
deleted file mode 100644
index 92f7a50b..00000000
--- a/harness/specs/tasks/fix-first-chat-no-response-fallback-poll.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-id: fix-first-chat-no-response-fallback-poll
-title: Restore fallback transcript polling so missing streamed events do not fail the first chat
-scenario: gateway-backend-communication
-taskType: runtime-bridge
-intent: Prevent the false "The model did not respond within 120 seconds" / "No response received from the model" errors on the first chat after Gateway startup when streamed chat/runtime events never reach the renderer, by polling chat.history as a fallback progress source during active sends.
-touchedAreas:
-  - harness/specs/tasks/fix-first-chat-no-response-fallback-poll.md
-  - src/stores/chat.ts
-  - tests/unit/chat-store-history-retry.test.ts
-expectedUserBehavior:
-  - When a send receives no streamed chat/runtime events (e.g. first run after Gateway startup or a silent WS drop), the renderer polls chat.history and surfaces transcript progress instead of firing the 120s/130s no-response safety errors.
-  - When the transcript shows a conclusive assistant reply, the run closes normally (sending cleared, reply rendered) without any error banner.
-  - While streamed events are fresh, the fallback poll issues no extra chat.history RPCs, so healthy streamed runs are unaffected.
-  - Renderer continues to use the existing gateway rpc Main-process boundary for chat.history polling.
-requiredProfiles:
-  - fast
-  - comms
-requiredRules:
-  - gateway-readiness-policy
-  - renderer-main-boundary
-  - backend-communication-boundary
-  - api-client-transport-policy
-requiredTests:
-  - pnpm exec vitest run tests/unit/chat-store-history-retry.test.ts
-  - pnpm run typecheck
-acceptance:
-  - The active sendMessage path arms a fallback transcript poll that only issues chat.history RPCs after sustained streamed-event silence.
-  - Streamed chat events no longer permanently clear the fallback poll timer; the poll self-throttles via event freshness instead.
-  - A run whose transcript contains a final assistant reply closes without emitting the no-response safety errors even when zero streamed events arrive.
-  - Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client invocation path.
-docs:
-  required: false
----
diff --git a/harness/specs/tasks/fix-gateway-agent-phase-end-state.md b/harness/specs/tasks/fix-gateway-agent-phase-end-state.md
deleted file mode 100644
index ea5bfceb..00000000
--- a/harness/specs/tasks/fix-gateway-agent-phase-end-state.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-id: fix-gateway-agent-phase-end-state
-title: Keep chat run active across non-terminal gateway phase end events
-scenario: gateway-backend-communication
-taskType: runtime-bridge
-intent: Prevent Gateway agent lifecycle hints from prematurely clearing the chat sending state while tool execution continues.
-touchedAreas:
-  - .gitignore
-  - harness/specs/tasks/fix-gateway-agent-phase-end-state.md
-  - src/stores/gateway.ts
-  - tests/e2e/chat-run-state-events.spec.ts
-  - tests/unit/gateway-events.test.ts
-expectedUserBehavior:
-  - The chat composer keeps showing the stop control while an agent run continues across tool rounds.
-  - Intermediate `phase: end` notifications refresh history without making the run look interrupted.
-  - Progressive streaming delta notifications without sequence numbers continue updating the visible response.
-requiredProfiles:
-  - fast
-  - comms
-requiredRules:
-  - gateway-readiness-policy
-  - renderer-main-boundary
-  - backend-communication-boundary
-  - api-client-transport-policy
-requiredTests:
-  - pnpm exec vitest run tests/unit/gateway-events.test.ts tests/unit/chat-event-dedupe.test.ts
-  - pnpm run build:vite && pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts
-  - pnpm run typecheck
-acceptance:
-  - Renderer does not add direct IPC calls.
-  - Renderer does not fetch Gateway HTTP directly.
-  - `phase: end` no longer clears `sending`, `activeRunId`, `pendingFinal`, or `lastUserMessageAt`.
-  - Terminal phases such as `completed` still clear chat run state.
-  - Gateway event dedupe does not suppress same-run delta notifications that do not carry `seq`.
-docs:
-  required: false
----
diff --git a/harness/specs/tasks/fix-gateway-ready-chat-history-reload.md b/harness/specs/tasks/fix-gateway-ready-session-catalog-reload.md
similarity index 64%
rename from harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
rename to harness/specs/tasks/fix-gateway-ready-session-catalog-reload.md
index 1777e7c3..8ca2d9db 100644
--- a/harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
+++ b/harness/specs/tasks/fix-gateway-ready-session-catalog-reload.md
@@ -1,24 +1,23 @@
 ---
-id: fix-gateway-ready-chat-history-reload
-title: Fix delayed sidebar chat history reload after gateway restart
+id: fix-gateway-ready-session-catalog-reload
+title: Fix delayed sidebar session catalog reload after gateway restart
 scenario: gateway-backend-communication
 taskType: runtime-bridge
-intent: Align gateway readiness signaling with sidebar history refresh after restart.
+intent: Align gateway readiness signaling with sidebar session catalog refresh after restart.
 touchedAreas:
-  - harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
+  - harness/specs/tasks/fix-gateway-ready-session-catalog-reload.md
   - electron/gateway/manager.ts
   - src/components/layout/Sidebar.tsx
   - src/stores/chat.ts
-  - src/stores/chat/history-actions.ts
   - src/pages/Chat/ChatInput.tsx
   - src/pages/Settings/index.tsx
   - src/pages/Setup/index.tsx
   - tests/unit/gateway-ready-fallback.test.ts
   - tests/unit/chat-input.test.tsx
-  - tests/unit/chat-store-history-retry.test.ts
+  - tests/unit/chat-load-sessions-startup.test.ts
   - tests/e2e/gateway-lifecycle.spec.ts
 expectedUserBehavior:
-  - After gateway restart, sidebar history reloads as soon as the gateway becomes RPC-ready.
+  - After gateway restart, the sidebar session catalog reloads as soon as the gateway becomes RPC-ready.
   - UI does not show a fully healthy green running state while gatewayReady is still false.
 requiredProfiles:
   - fast
@@ -31,13 +30,13 @@ requiredRules:
 requiredTests:
   - tests/unit/gateway-ready-fallback.test.ts
   - tests/unit/chat-input.test.tsx
-  - tests/unit/chat-store-history-retry.test.ts
+  - tests/unit/chat-load-sessions-startup.test.ts
   - tests/e2e/gateway-lifecycle.spec.ts
 acceptance:
   - Renderer does not add direct IPC calls.
   - Renderer does not fetch Gateway HTTP directly.
   - Gateway running-but-not-ready state is surfaced distinctly from fully ready.
-  - Sidebar reloads sessions/history when gatewayReady becomes true after a restart.
+  - Sidebar reloads the session catalog when gatewayReady becomes true after a restart.
 docs:
   required: false
 ---
diff --git a/harness/specs/tasks/fix-image-generation-message-delivery.md b/harness/specs/tasks/fix-image-generation-message-delivery.md
index 6055f9c1..ec78e81d 100644
--- a/harness/specs/tasks/fix-image-generation-message-delivery.md
+++ b/harness/specs/tasks/fix-image-generation-message-delivery.md
@@ -1,21 +1,22 @@
 ---
 id: fix-image-generation-message-delivery
-title: Surface async image-generation message-tool deliveries in Chat
+title: Surface async image-generation deliveries in ACP Chat
 scenario: gateway-backend-communication
 taskType: runtime-bridge
-intent: Ensure generated images delivered through the OpenClaw message tool remain visible in ClawX chat even when Gateway does not append an assistant-media transcript bubble.
+intent: Ensure trusted OpenClaw image-generation completion deliveries remain visible in ACP Chat through the bounded compatibility projection.
 touchedAreas:
   - harness/specs/tasks/fix-image-generation-message-delivery.md
+  - src/lib/acp/image-generation-compat.ts
+  - src/lib/acp/reducer.ts
   - src/pages/Chat/index.tsx
-  - src/stores/chat.ts
-  - src/stores/chat/helpers.ts
+  - src/stores/acp-chat-session.ts
   - tests/e2e/chat-run-state-events.spec.ts
-  - tests/unit/chat-helpers-enrichment.test.ts
-  - tests/unit/chat-page-execution-graph.test.tsx
+  - tests/unit/acp-image-generation-compat.test.ts
+  - tests/unit/acp-chat-store.test.ts
 expectedUserBehavior:
-  - When async image generation completes and the message tool returns mediaUrl/mediaUrls, the sourceReply caption and image appear as a final assistant reply.
-  - Chat image generation pending state can settle from message-tool delivery records without relying solely on assistant-media bubbles.
-  - Renderer continues to use existing Host API / Gateway history paths and does not call Gateway HTTP directly.
+  - When async image generation completes with trusted internal-UI delivery evidence, the sourceReply caption and image appear in the matching ACP timeline.
+  - ACP image-generation pending state settles from accepted completion evidence without relying on a legacy Chat message renderer.
+  - Renderer continues to use existing Host API and Host event boundaries and does not call Gateway HTTP directly.
 requiredProfiles:
   - fast
   - comms
@@ -25,14 +26,17 @@ requiredRules:
   - api-client-transport-policy
   - host-events-fallback-policy
   - gateway-readiness-policy
+  - acp-chat-state-and-history
+  - acp-compatibility-content-safety
+  - diagnostics-trace-safety
 requiredTests:
-  - pnpm exec vitest run tests/unit/chat-helpers-enrichment.test.ts tests/unit/chat-page-execution-graph.test.tsx
-  - pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts
+  - pnpm exec vitest run tests/unit/acp-image-generation-compat.test.ts tests/unit/acp-chat-store.test.ts
+  - pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts -g "projects OpenClaw image-generation"
   - pnpm run typecheck
 acceptance:
-  - message tool call arguments using media/mediaUrl/mediaUrls are promoted to chat attachments unless a matching internal-UI delivery reply exists.
-  - successful internal-UI message tool results with mediaUrl/mediaUrls/sourceReply.mediaUrls become a standalone assistant reply before toolresult rows are filtered.
-  - Existing safeguards still avoid promoting arbitrary image paths from read/exec tool output.
+  - ClawX accepts only trusted ACP or Gateway completion evidence that matches the ACP session and recent image-generation context.
+  - Internal-UI sourceReply text remains authoritative for successful media replies and text-only failure replies.
+  - Existing safeguards still reject arbitrary image paths and generic tool output without approved image-generation context.
 docs:
   required: false
 ---
diff --git a/harness/specs/tasks/fix-target-agent-first-send.md b/harness/specs/tasks/fix-target-agent-first-send.md
index 01528247..fb2dc7dd 100644
--- a/harness/specs/tasks/fix-target-agent-first-send.md
+++ b/harness/specs/tasks/fix-target-agent-first-send.md
@@ -11,7 +11,6 @@ touchedAreas:
   - harness/specs/rules/session-workspace-authority.md
   - src/pages/Chat/index.tsx
   - shared/chat/types.ts
-  - src/stores/chat/session-actions.ts
   - src/stores/chat.ts
   - tests/unit/chat-acp-page.test.tsx
   - tests/e2e/chat-acp-inline-timeline.spec.ts
diff --git a/harness/specs/tasks/hard-delete-session-jsonl.md b/harness/specs/tasks/hard-delete-session-jsonl.md
index a5eb2318..805d47d5 100644
--- a/harness/specs/tasks/hard-delete-session-jsonl.md
+++ b/harness/specs/tasks/hard-delete-session-jsonl.md
@@ -8,9 +8,9 @@ touchedAreas:
   - electron/main/ipc-handlers.ts
   - electron/services/sessions-api.ts
   - electron/utils/session-files.ts
-  - src/stores/chat/session-actions.ts
   - src/stores/chat.ts
   - tests/unit/host-services.test.ts
+  - tests/unit/chat-session-management.test.ts
   - harness/specs/tasks/hard-delete-session-jsonl.md
   - AGENTS.md
 expectedUserBehavior:
@@ -19,19 +19,18 @@ expectedUserBehavior:
   - When the pointer's runtimeFile points outside the sessions/ folder (the OPENCLAW_TRAJECTORY_DIR override), that off-disk runtime file is also unlinked so no orphan trajectory remains anywhere on disk.
   - The session entry is removed from sessions.json so OpenClaw sessions.list stops returning it.
   - The sidebar list, sessionLabels and sessionLastActivity for the deleted key are cleared in the renderer store.
-  - Any pending optimistic user message cache for the deleted session key is cleared so a later history reload cannot resurrect deleted chat bubbles.
   - Token usage history reported by the Dashboard stops including the deleted session.
 requiredProfiles:
   - fast
   - comms
 requiredTests:
   - tests/unit/host-services.test.ts
-  - tests/unit/chat-session-actions.test.ts
+  - tests/unit/chat-session-management.test.ts
 acceptance:
   - Renderer continues to use src/lib/host-api.ts and src/lib/api-client.ts; no new direct ipcRenderer or Gateway HTTP calls.
   - Typed host session deletion and the legacy session:delete IPC handler unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift.
   - The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry.
-  - Renderer delete-session paths clear any in-memory pending optimistic user messages for the deleted key before subsequent history loads run.
+  - Renderer delete-session paths clear catalog labels, activity, selection, and persisted attention for deleted keys.
   - agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json).
   - Absolute-path detection accepts POSIX paths, Windows backslash paths (C:\...) and Windows forward-slash paths (C:/...) so the sweep works on every supported OS.
   - The sweep also unlinks .trajectory.jsonl and .trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer.
diff --git a/harness/specs/tasks/maintain-session-catalog-reconciliation.example.md b/harness/specs/tasks/maintain-session-catalog-reconciliation.example.md
new file mode 100644
index 00000000..26a4abfe
--- /dev/null
+++ b/harness/specs/tasks/maintain-session-catalog-reconciliation.example.md
@@ -0,0 +1,29 @@
+---
+id: maintain-session-catalog-reconciliation
+title: Maintain session catalog reconciliation alongside ACP Chat
+scenario: gateway-backend-communication
+taskType: runtime-bridge
+intent: Adjust session catalog reconciliation without creating a second ACP history or prompt-state authority.
+touchedAreas:
+  - src/stores/chat/session-catalog.ts
+  - src/stores/chat.ts
+expectedUserBehavior:
+  - Gateway reconnects reconcile normalized session rows without replacing the selected ACP timeline.
+  - Delayed catalog metadata cannot overwrite a newer local session selection or mutation.
+requiredProfiles:
+  - fast
+  - comms
+requiredTests:
+  - tests/unit/session-catalog.test.ts
+  - tests/unit/chat-session-management.test.ts
+acceptance:
+  - Renderer does not add direct IPC calls.
+  - Renderer does not fetch Gateway HTTP directly.
+  - ACP session/load remains the ordinary Chat history authority.
+  - Session catalog updates remain generation- and mutation-fenced.
+  - Comms replay and compare pass.
+docs:
+  required: false
+---
+
+Structural example for maintaining the Gateway-backed session catalog alongside ACP Chat through the backend communication scenario. Copy it to a task-specific file and replace the example identity before starting an AI Coding change.
diff --git a/harness/specs/tasks/office-document-preview.md b/harness/specs/tasks/office-document-preview.md
index 470e9a9a..9b97d3ae 100644
--- a/harness/specs/tasks/office-document-preview.md
+++ b/harness/specs/tasks/office-document-preview.md
@@ -30,7 +30,6 @@ touchedAreas:
   - shared/i18n/locales/ru/chat.json
   - tests/unit/harness-specs.test.ts
   - tests/unit/generated-files.test.ts
-  - tests/unit/generated-files-panel.test.tsx
   - tests/unit/open-file-utils.test.ts
   - tests/unit/file-preview-body.test.tsx
   - tests/unit/workspace-browser-body.test.tsx
@@ -54,7 +53,7 @@ expectedUserBehavior:
   - Authorized PPTX files at or below 20 MB render one slide at a time with localized previous and next controls, while at most one viewer is mounted in the Renderer.
   - The Chat Preview header offers a localized fullscreen toggle that fills the Renderer viewport, preserves the current target and slide position, and exits from its header control or Escape.
   - Legacy DOC and PPT files, remote attachments, and over-limit Office files retain their existing safe system-open, unsupported, or too-large behavior according to target authority.
-  - Existing image, PDF, spreadsheet, HTML, Markdown, source, diff, attachment, and workspace behavior remains unchanged.
+  - Existing image, PDF, spreadsheet, HTML, Markdown, source, ACP Changes, attachment, and workspace behavior remains unchanged.
 requiredProfiles:
   - fast
   - e2e
@@ -66,7 +65,7 @@ requiredRules:
   - office-preview-safety
   - docs-sync
 requiredTests:
-  - pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/generated-files-panel.test.tsx tests/unit/open-file-utils.test.ts tests/unit/file-preview-body.test.tsx tests/unit/workspace-browser-body.test.tsx tests/unit/rich-file-viewers.test.tsx tests/unit/office-file-viewers.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/i18n-locale-parity.test.ts tests/unit/harness-specs.test.ts
+  - pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/open-file-utils.test.ts tests/unit/file-preview-body.test.tsx tests/unit/workspace-browser-body.test.tsx tests/unit/rich-file-viewers.test.tsx tests/unit/office-file-viewers.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/i18n-locale-parity.test.ts tests/unit/harness-specs.test.ts
   - pnpm run typecheck
   - pnpm run lint:check
   - pnpm run build:vite
diff --git a/harness/specs/tasks/remove-pre-acp-chat.md b/harness/specs/tasks/remove-pre-acp-chat.md
new file mode 100644
index 00000000..373a1e67
--- /dev/null
+++ b/harness/specs/tasks/remove-pre-acp-chat.md
@@ -0,0 +1,140 @@
+---
+id: remove-pre-acp-chat
+title: Remove pre-ACP Chat implementation
+scenario: gateway-backend-communication
+taskType: runtime-bridge
+intent: Remove the superseded pre-ACP Chat history, send, runtime-state, and visualization pipeline while preserving ACP Chat, bounded image-generation compatibility, and session-management behavior.
+touchedAreas:
+  - AGENTS.md
+  - package.json
+  - harness/specs/tasks/**
+  - harness/specs/scenarios/acp-chat-experience.md
+  - harness/specs/scenarios/chat-workspace-and-navigation.md
+  - harness/specs/scenarios/gateway-backend-communication.md
+  - harness/specs/scenarios/gateway-startup-diagnostics.md
+  - harness/specs/rules/acp-chat-state-and-history.md
+  - harness/specs/rules/acp-compatibility-content-safety.md
+  - harness/specs/rules/host-events-fallback-policy.md
+  - harness/specs/rules/sidebar-session-attention-authority.md
+  - harness/reference/acp-chat.md
+  - harness/reference/acp-generated-media-and-diagnostics.md
+  - harness/reference/sidebar-session-attention.md
+  - shared/acp-chat/**
+  - shared/chat/**
+  - shared/chat-runtime-events.ts
+  - shared/host-api/contract.ts
+  - shared/host-events/contract.ts
+  - electron/gateway/event-dispatch.ts
+  - electron/gateway/chat-runtime-events.ts
+  - electron/gateway/client.ts
+  - electron/gateway/manager.ts
+  - electron/gateway/rpc-backpressure.ts
+  - electron/main/index.ts
+  - electron/main/ipc/**
+  - electron/main/ipc-handlers.ts
+  - electron/services/acp-chat-service.ts
+  - electron/services/chat-api.ts
+  - electron/services/gateway-api.ts
+  - src/lib/acp/**
+  - src/lib/generated-files.ts
+  - src/components/file-preview/**
+  - src/lib/host-api.ts
+  - src/lib/host-events.ts
+  - src/stores/**
+  - src/pages/Chat/**
+  - src/components/layout/Sidebar.tsx
+  - src/styles/globals.css
+  - shared/i18n/locales/en/chat.json
+  - shared/i18n/locales/zh/chat.json
+  - shared/i18n/locales/ja/chat.json
+  - shared/i18n/locales/ru/chat.json
+  - tests/unit/**
+  - tests/e2e/**
+  - tests/e2e/fixtures/**
+  - tests/e2e/chat-acp-process-timeline.spec.ts
+  - README.md
+  - README.zh-CN.md
+  - README.ja-JP.md
+expectedUserBehavior:
+  - Opening or revisiting a normal conversation renders its history only from ACP session/load replay, with the existing bounded ACP transcript supplements retained.
+  - Sending and stopping from the composer use only ACP session/prompt and session/cancel.
+  - ACP text, thinking, tools, permissions, plans, attachments, generated files, generated images, and errors continue to render through the ACP timeline without the legacy ChatMessage or Execution Graph pipeline.
+  - Session discovery, sidebar ordering and attention, title hydration, history supplementation, selection, deletion, and rename continue to behave as before.
+  - Catalog reconciliation repairs removed selections atomically, resets suppression and timestamp fences across Gateway generations, withholds a selected persisted session until its workspace summary settles, and never overwrites a newer local catalog or selection mutation with the delayed snapshot.
+  - A failed hard delete keeps the session and confirmation dialog intact; navigation and dialog closure happen only after host-confirmed deletion.
+  - Bulk deletion repairs a deleted current selection atomically with the remaining catalog, preferring a valid non-cron/non-channel session or creating an agent-local placeholder.
+  - Asynchronous OpenClaw image-generation completions continue to appear when accepted by the existing bounded ACP compatibility checks.
+requiredProfiles:
+  - fast
+  - comms
+  - e2e
+requiredRules:
+  - renderer-main-boundary
+  - backend-communication-boundary
+  - api-client-transport-policy
+  - host-api-fallback-policy
+  - host-events-fallback-policy
+  - gateway-readiness-policy
+  - acp-chat-state-and-history
+  - acp-compatibility-content-safety
+  - attachment-access-safety
+  - diagnostics-trace-safety
+  - session-workspace-authority
+  - sidebar-session-attention-authority
+  - tool-derived-file-safety
+  - ui-i18n-design-tokens
+  - comms-regression
+  - docs-sync
+requiredTests:
+  - pnpm run typecheck
+  - pnpm exec vitest run tests/unit/acp-host-contract.test.ts tests/unit/acp-chat-service.test.ts tests/unit/acp-reducer.test.ts tests/unit/acp-chat-store.test.ts tests/unit/acp-chat-components.test.tsx tests/unit/chat-acp-page.test.tsx tests/unit/chat-toolbar.test.tsx
+  - pnpm exec vitest run tests/unit/acp-image-generation-compat.test.ts tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-session-management.test.ts tests/unit/chat-session-selection.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
+  - pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/host-services.test.ts tests/unit/gateway-event-dispatch.test.ts
+  - pnpm exec vitest run tests/unit/gateway-manager-diagnostics.test.ts tests/unit/gateway-ws-trace.test.ts tests/unit/stores.test.ts
+  - pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/file-preview-body.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/openclaw-file-activities.test.ts tests/unit/i18n-locale-parity.test.ts
+  - pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts tests/e2e/chat-acp-attachments.spec.ts tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-acp-process-timeline.spec.ts tests/e2e/chat-sidebar-session-attention.spec.ts
+  - pnpm run comms:replay
+  - pnpm run comms:compare
+  - pnpm run lint:check
+  - pnpm run build:vite
+  - pnpm harness validate --spec harness/specs/tasks/remove-pre-acp-chat.md
+  - pnpm run harness:ci
+  - git diff --check
+acceptance:
+  - ACP session/load replay is the only normal Chat history render path; no Gateway Chat history, transcript reconstruction, legacy message array, or persisted parallel replay ledger renders ordinary Chat turns.
+  - ACP session/prompt and session/cancel are the only active composer send and stop paths; ACP permission responses remain a separate protocol action.
+  - Legacy ChatMessage, Execution Graph, task visualization, RawMessage rendering helpers, and useChatStore send/runtime state and actions are removed from production code and their legacy-only tests are deleted or replaced with ACP assertions.
+  - ACP-owned in-memory prompt state, including sending, cancelling, optimistic user segments, retained live prompt snapshots, and generation guards, remains intact.
+  - gateway:chat-message and chat:runtime-event contracts, Main forwarding, and Renderer subscriptions remain only where required for bounded ACP image-generation completion compatibility; they do not feed ordinary messages, history, composer state, tool state, task visualization, or session run state.
+  - Existing image-generation compatibility continues to require matching ACP session, generation, recent image_generate context, trusted completion evidence, deduplication, and stale-result rejection.
+  - Session catalog, sidebar and attention state, ACP history supplements, automatic title hydration, session selection, hard delete, and rename behavior remain supported without depending on removed legacy message or runtime state.
+  - Session deletion or hiding never leaves a dangling current selection; fallback selection follows the normal non-cron/non-channel policy or creates a fresh agent-local placeholder.
+  - Single and bulk deletion use the same current-selection repair policy and never select a cron/channel survivor implicitly or leave the absent default key selected.
+  - Selected persisted sessions with missing workspace identity are not published to ACP consumers before batched summary hydration settles, and stale summaries remain fenced by Gateway generation, session incarnation, and a local session catalog/selection mutation revision.
+  - The unreachable chat media-send shared/Main/Renderer contract is removed; ACP load, prompt, cancel, and permission operations remain the exact typed Chat host actions.
+  - The unused standalone Gateway client and Chat-history-only RPC backpressure layer are removed; typed generic Gateway RPC validates the same method and timeout inputs before delegating directly to GatewayManager.rpc.
+  - Generic Gateway RPC diagnostics and proxy tests use non-Chat methods; one Electron E2E assertion prevents ordinary Chat from resurrecting a chat.history call.
+  - No OpenClaw source, installed package source, bundled OpenClaw distribution, or OpenClaw patch is modified.
+  - Typecheck, focused ACP and host tests, relevant ACP Electron E2E, communication regression checks, task validation, and harness CI pass.
+docs:
+  required: true
+---
+
+## Scope
+
+Delete the pre-ACP Chat implementation after separating the session catalog and session-management responsibilities that are still consumed by ACP Chat. Remove legacy renderer history reduction, Gateway send orchestration, runtime send state, Execution Graph/task derivation, and their dead contracts, services, localization, and tests.
+
+Keep the ACP timeline and composer as the sole normal Chat implementation. Preserve only the explicitly bounded Gateway-event bridge needed to project asynchronous image-generation completion evidence into the ACP timeline.
+
+## Preserved Boundaries
+
+- ACP `session/load`, `session/prompt`, `session/cancel`, `session/update`, and `session/request_permission` retain their existing Main/Renderer ownership and generation guards.
+- The existing transcript-derived ACP supplements remain bounded to approved generated-media and attachment content plus metadata-only timing; they do not become a second ordinary history path.
+- Session catalog, sidebar attention, workspace binding, title hydration, selection, deletion, and rename remain independent of legacy message rendering and runtime send state.
+
+## Out Of Scope
+
+- Modifying OpenClaw source, package contents, or bundled runtime behavior.
+- Replacing ACP replay with Gateway Chat history or broad transcript reconstruction.
+- Removing or widening the bounded ACP image-generation completion compatibility path.
+- Redesigning the ACP timeline, composer, sidebar, workspace, attachment, permission, or file-activity experiences.
diff --git a/harness/specs/tasks/render-cron-run-live-status.md b/harness/specs/tasks/render-cron-run-live-status.md
index 3f405c51..0cf5255f 100644
--- a/harness/specs/tasks/render-cron-run-live-status.md
+++ b/harness/specs/tasks/render-cron-run-live-status.md
@@ -3,21 +3,18 @@ id: render-cron-run-live-status
 title: Render live execution status for cron-triggered runs without a session switch
 scenario: gateway-backend-communication
 taskType: runtime-bridge
-intent: When a scheduled (cron) job fires while the user is viewing that cron session, ClawX must render the live running state (Thinking indicator, Execution Graph, tool steps) in realtime. Today the Gateway streams runtime events under the run-scoped session key (agent::cron::run:) while the UI tracks the base cron key (agent::cron:), so events are dropped by strict session-key equality and the user must switch sessions to force a transcript reload.
+intent: When a scheduled cron job runs while the user is viewing that session, render its ACP updates and available cron history in the inline timeline without requiring a session switch.
 touchedAreas:
   - harness/specs/tasks/render-cron-run-live-status.md
+  - src/lib/cron-session-history.ts
+  - src/stores/acp-chat-session.ts
   - src/stores/chat/cron-session-utils.ts
-  - src/stores/chat.ts
-  - src/stores/gateway.ts
-  - src/components/layout/Sidebar.tsx
   - tests/unit/cron-session-utils.test.ts
-  - tests/unit/gateway-events.test.ts
   - tests/e2e/cron-run-live-status.spec.ts
 expectedUserBehavior:
-  - When a cron job triggers while the user is viewing that cron session, the renderer adopts the run, surfaces the running/Thinking state, and renders the Execution Graph live from streamed runtime events.
-  - Runtime events whose sessionKey carries the run-scoped suffix are treated as belonging to the equivalent base cron session the user is viewing.
-  - When the cron run ends, the renderer reloads the transcript for the current session so the completed graph and final reply render without a manual session switch.
-  - Background :main heartbeat runs continue to NOT surface a Thinking indicator.
+  - ACP tool and message updates for the selected cron session render inline without a manual session switch.
+  - Run-scoped cron session keys are normalized to the equivalent base cron session where catalog reconciliation requires it.
+  - When ACP replay is empty, authorized cron session history can supply the visible prompt and final reply.
   - Renderer continues to use Host events / api-client boundaries; no new direct IPC or Gateway HTTP calls are added.
 requiredProfiles:
   - fast
@@ -30,13 +27,12 @@ requiredRules:
   - gateway-readiness-policy
 requiredTests:
   - pnpm exec vitest run tests/unit/cron-session-utils.test.ts
-  - pnpm exec vitest run tests/unit/gateway-events.test.ts
+  - pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
   - pnpm run typecheck
 acceptance:
   - A cron session-key equivalence helper treats the base cron key and its run-scoped variant as the same session.
-  - chat store handleChatEvent / handleRuntimeEvent apply cron run-scoped events to the equivalent base cron session currently in view.
-  - Cron sessions are treated as trackable inbound runs so run.started arms the running state, while :main heartbeats remain suppressed.
-  - gateway runtime-event dispatch reloads history for the current cron session on run end (and start) using equivalence rather than strict equality.
+  - ACP notifications for the selected cron session reduce into the same ordered in-memory timeline as other Chat sessions.
+  - Empty ACP replay may be supplemented only by the authorized cron session-history route for that session.
   - Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client / host-events path.
 docs:
   required: false
diff --git a/harness/specs/tasks/replace-markdown-renderer-with-streamdown.md b/harness/specs/tasks/replace-markdown-renderer-with-streamdown.md
new file mode 100644
index 00000000..9ca2ae51
--- /dev/null
+++ b/harness/specs/tasks/replace-markdown-renderer-with-streamdown.md
@@ -0,0 +1,88 @@
+---
+id: replace-markdown-renderer-with-streamdown
+title: Replace application Markdown rendering with Streamdown
+scenario: acp-chat-experience
+taskType: runtime-bridge
+intent: Replace ReactMarkdown with one stable Streamdown configuration for streaming ACP Chat Markdown and static file previews while preserving content safety, presentation, and measured Chat performance.
+touchedAreas:
+  - harness/reference/markdown-rendering.md
+  - harness/specs/tasks/replace-markdown-renderer-with-streamdown.md
+  - harness/specs/rules/markdown-rendering-safety-and-performance.md
+  - harness/specs/scenarios/acp-chat-experience.md
+  - harness/specs/scenarios/chat-workspace-and-navigation.md
+  - package.json
+  - pnpm-lock.yaml
+  - tailwind.config.js
+  - src/main.tsx
+  - src/components/markdown/**
+  - src/components/file-preview/MarkdownPreview.tsx
+  - src/pages/Chat/AcpTimeline.tsx
+  - src/pages/Chat/AcpAssistantTurn.tsx
+  - src/pages/Chat/AcpMessageSegment.tsx
+  - src/pages/Chat/index.tsx
+  - src/styles/globals.css
+  - tests/unit/harness-specs.test.ts
+  - tests/unit/streamdown-config.test.tsx
+  - tests/unit/markdown-preview.test.tsx
+  - tests/unit/file-preview-body.test.tsx
+  - tests/unit/acp-chat-components.test.tsx
+  - tests/unit/browser-link.test.tsx
+  - tests/e2e/markdown-file-preview.spec.ts
+  - tests/e2e/chat-streamdown-rendering.spec.ts
+  - tests/e2e/chat-code-block-wrap.spec.ts
+  - tests/e2e/chat-latex-rendering.spec.ts
+  - tests/e2e/chat-assistant-markdown-plain.spec.ts
+  - tests/e2e/chat-table-header-light.spec.ts
+  - tests/e2e/renderer-performance.spec.ts
+  - README.md
+  - README.zh-CN.md
+  - README.ja-JP.md
+expectedUserBehavior:
+  - Assistant and process Markdown renders incrementally during ACP Chat streaming, while completed blocks remain stable and only the active final Markdown part receives subtle word animation and a circle caret.
+  - Markdown files render in static mode with highlighted fenced code, KaTeX math, CJK-aware parsing, and omitted YAML or TOML frontmatter; Mermaid fences remain code.
+  - User messages and tool output remain literal, raw HTML remains visible literal text, links remain inert, and unsafe Markdown image sources remain rejected.
+  - Chat and preview preserve prose spacing and compact lists; tables show only the cell grid; fenced code preserves source lines and soft-wraps with a compact right-aligned language header and a vertically centered localized copy action.
+requiredProfiles:
+  - fast
+  - e2e
+requiredRules:
+  - renderer-main-boundary
+  - acp-compatibility-content-safety
+  - attachment-access-safety
+  - ui-i18n-design-tokens
+  - markdown-rendering-safety-and-performance
+  - docs-sync
+requiredTests:
+  - pnpm exec vitest run tests/unit/harness-specs.test.ts
+  - pnpm exec vitest run tests/unit/streamdown-config.test.tsx tests/unit/markdown-preview.test.tsx tests/unit/file-preview-body.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/browser-link.test.tsx
+  - pnpm run typecheck
+  - pnpm run lint:check
+  - pnpm run build:vite
+  - pnpm exec playwright test tests/e2e/markdown-file-preview.spec.ts tests/e2e/chat-streamdown-rendering.spec.ts tests/e2e/chat-code-block-wrap.spec.ts tests/e2e/chat-latex-rendering.spec.ts tests/e2e/chat-assistant-markdown-plain.spec.ts tests/e2e/chat-table-header-light.spec.ts --workers=1
+  - pnpm run perf:chat
+  - pnpm exec vite build --sourcemap
+  - pnpm harness validate --spec harness/specs/tasks/replace-markdown-renderer-with-streamdown.md
+  - pnpm run harness:ci
+acceptance:
+  - ACP assistant and process Markdown uses Streamdown streaming mode, Markdown file preview uses static mode, and user messages plus tool output stay outside Streamdown.
+  - One module-scoped configuration enables code, math with single-dollar support, and CJK plugins without installing or configuring a Mermaid plugin.
+  - Raw HTML is rendered as literal text, links remain inert through BrowserLink, Markdown images retain isSafeAcpImageSource validation, only localized code copy is enabled, and table, Mermaid, code-download, and line-number controls stay disabled.
+  - YAML and TOML frontmatter is parsed and omitted without the previous custom metadata card.
+  - Only the open assistant segment's final Markdown part animates, using word-level fadeIn with duration 140 and stagger 0 plus a circle caret; completed blocks do not restart animation.
+  - Focused unit and Electron E2E checks cover incomplete streaming Markdown, code highlighting, wrapping and copying, prose spacing, compact lists, cell-only table borders, math, CJK punctuation, Mermaid-as-code, raw HTML, inert links, safe images, literal user and tool content, frontmatter omission, active-part animation, and retained visual contracts.
+  - Production source-map review confirms the expected Streamdown and Shiki cost, no direct @streamdown/mermaid dependency, and no unexpected eager Mermaid renderer chunk.
+  - Three before and three after runs of the same 80-turn and 300-chunk workload retain metrics and CPU profiles; median Renderer TaskDuration and ScriptDuration each regress by no more than 10 percent, and median ScriptDuration or sampled Markdown and React CPU time improves by at least 10 percent.
+  - ReactMarkdown and obsolete direct Markdown plugin dependencies are absent after both renderers migrate, KaTeX remains direct with exactly one stylesheet import, and multilingual README documentation matches the delivered behavior without claiming Mermaid support.
+docs:
+  required: true
+---
+
+## Scope
+
+This task covers the shared renderer configuration, ACP Chat streaming presentation, static Markdown file preview, dependency cleanup, focused safety and presentation tests, Electron E2E coverage, bundle inspection, and measured before/after performance validation. It does not change ACP transport, timeline reduction, event batching, store update cadence, Renderer/Main APIs, or fallback policy.
+
+The durable renderer, safety, and profiling contract is documented in `harness/reference/markdown-rendering.md` and enforced by `harness/specs/rules/markdown-rendering-safety-and-performance.md`.
+
+## Validation Notes
+
+Performance artifacts are generated fixture data and remain ignored under `test-results/`. Compare three-run medians from the same machine; do not encode machine-specific absolute timings in automated tests or weaken the percentage thresholds when a migration misses them.
diff --git a/harness/specs/tasks/restore-hardware-accelerated-rendering.md b/harness/specs/tasks/restore-hardware-accelerated-rendering.md
new file mode 100644
index 00000000..bd5edb88
--- /dev/null
+++ b/harness/specs/tasks/restore-hardware-accelerated-rendering.md
@@ -0,0 +1,53 @@
+---
+id: restore-hardware-accelerated-rendering
+title: Restore hardware-accelerated Electron rendering
+scenario: acp-chat-experience
+taskType: runtime-bridge
+intent: Restore Chromium GPU compositing and rasterization by default, then retain a rich-Markdown interaction profile that catches sidebar and scroll regressions missed by streaming-only measurements.
+touchedAreas:
+  - electron/main/index.ts
+  - tests/unit/main-hardware-acceleration.test.ts
+  - tests/e2e/hardware-acceleration.spec.ts
+  - tests/e2e/renderer-performance.spec.ts
+  - harness/reference/electron-rendering-performance.md
+  - harness/specs/rules/electron-rendering-performance.md
+  - harness/specs/scenarios/acp-chat-experience.md
+  - harness/specs/scenarios/chat-workspace-and-navigation.md
+  - harness/specs/tasks/restore-hardware-accelerated-rendering.md
+  - README.md
+  - README.zh-CN.md
+  - README.ja-JP.md
+expectedUserBehavior:
+  - Long rich-Markdown conversations scroll smoothly on supported desktop GPUs.
+  - Collapsing or expanding the sidebar preserves its existing animation without software-rasterization frame drops.
+  - A user with a broken graphics driver can still opt into Chromium's native `--disable-gpu` fallback.
+requiredProfiles:
+  - fast
+  - e2e
+requiredRules:
+  - electron-rendering-performance
+  - markdown-rendering-safety-and-performance
+  - diagnostics-trace-safety
+  - docs-sync
+requiredTests:
+  - pnpm exec vitest run tests/unit/main-hardware-acceleration.test.ts
+  - pnpm run build:vite
+  - pnpm exec playwright test tests/e2e/hardware-acceleration.spec.ts --workers=1
+  - pnpm run perf:chat
+  - pnpm run typecheck
+  - pnpm run lint:check
+  - pnpm harness validate --spec harness/specs/tasks/restore-hardware-accelerated-rendering.md
+acceptance:
+  - Electron Main no longer disables hardware acceleration globally.
+  - A desktop Electron E2E confirms hardware acceleration and GPU compositing are enabled where the runner provides a real GPU.
+  - The existing sidebar animation and Markdown rendering behavior remain unchanged.
+  - The permanent performance command records frame pacing and profiles for a generated rich static Markdown conversation during sidebar collapse and vertical scrolling.
+  - Real-conversation before/after profiles on the same machine show the stable frame-pacing regression is removed without relying on machine-specific automated timing thresholds.
+  - The native `--disable-gpu` troubleshooting path remains available without a new settings or compatibility layer.
+docs:
+  required: true
+---
+
+## Scope
+
+This task removes the application-owned global software-rendering policy, adds focused policy/runtime coverage, and extends the existing profiling harness to the idle interactions from the reported real conversation. It does not redesign the sidebar, virtualize Chat history, change Markdown output, or profile the GPU process directly.
diff --git a/harness/specs/tasks/sidebar-session-attention.md b/harness/specs/tasks/sidebar-session-attention.md
index 52ecd849..6aecfa7d 100644
--- a/harness/specs/tasks/sidebar-session-attention.md
+++ b/harness/specs/tasks/sidebar-session-attention.md
@@ -29,8 +29,7 @@ touchedAreas:
   - shared/i18n/locales/ru/chat.json
   - tests/unit/gateway-events.test.ts
   - tests/unit/gateway-event-dispatch.test.ts
-  - tests/unit/gateway-bisection-0d794cd.test.ts
-  - tests/unit/chat-store-history-retry.test.ts
+  - tests/unit/chat-session-management.test.ts
   - tests/unit/chat-store-session-label-fetch.test.ts
   - tests/unit/session-label-hydration.test.ts
   - tests/unit/sidebar-session-buckets.test.ts
@@ -59,7 +58,7 @@ requiredRules:
   - comms-regression
   - docs-sync
 requiredTests:
-  - pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/session-attention.test.ts tests/unit/session-status.test.ts tests/unit/session-catalog.test.ts tests/unit/session-label-hydration.test.ts tests/unit/gateway-events.test.ts tests/unit/chat-store-history-retry.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
+  - pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/session-attention.test.ts tests/unit/session-status.test.ts tests/unit/session-catalog.test.ts tests/unit/session-label-hydration.test.ts tests/unit/gateway-events.test.ts tests/unit/chat-session-management.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
   - pnpm exec vitest run tests/unit/i18n-locale-parity.test.ts tests/unit/gateway-event-dispatch.test.ts
   - pnpm run typecheck
   - pnpm run lint:check
diff --git a/harness/specs/tasks/use-openclaw-native-config-delivery.md b/harness/specs/tasks/use-openclaw-native-config-delivery.md
new file mode 100644
index 00000000..4a70f138
--- /dev/null
+++ b/harness/specs/tasks/use-openclaw-native-config-delivery.md
@@ -0,0 +1,78 @@
+---
+id: use-openclaw-native-config-delivery
+title: Use OpenClaw-native config delivery and restart planning
+scenario: gateway-backend-communication
+taskType: runtime-bridge
+intent: Make one Main-owned coordinator the only ClawX path for OpenClaw config mutation, using config.get and config.set while running and a file fallback before startup.
+touchedAreas:
+  - harness/specs/tasks/use-openclaw-native-config-delivery.md
+  - harness/specs/rules/openclaw-config-delivery.md
+  - harness/specs/scenarios/gateway-backend-communication.md
+  - harness/reference/openclaw-config-delivery.md
+  - electron/gateway/config-delivery.ts
+  - electron/gateway/manager.ts
+  - electron/gateway/ws-trace.ts
+  - electron/main/index.ts
+  - electron/main/ipc-handlers.ts
+  - electron/services/agents-api.ts
+  - electron/services/channels-api.ts
+  - electron/services/providers/provider-runtime-sync.ts
+  - electron/utils/channel-config.ts
+  - electron/utils/agent-config.ts
+  - electron/utils/openclaw-auth.ts
+  - electron/utils/openclaw-image-generation.ts
+  - electron/utils/plugin-install.ts
+  - electron/utils/skill-config.ts
+  - tests/unit/gateway-config-delivery.test.ts
+  - tests/unit/gateway-ws-trace.test.ts
+  - tests/unit/agent-config.test.ts
+  - tests/unit/channel-config.test.ts
+  - tests/unit/host-services.test.ts
+  - tests/unit/openclaw-auth.test.ts
+  - tests/unit/openclaw-image-generation.test.ts
+  - tests/unit/provider-runtime-sync.test.ts
+  - README.md
+  - README.zh-CN.md
+  - README.ja-JP.md
+expectedUserBehavior:
+  - Saving providers, agents, channels, bindings, skills, or other hot-applicable OpenClaw configuration does not replace the Gateway process.
+  - Every ClawX mutation of the active OpenClaw config goes through one coordinator-owned read-modify-write transaction.
+  - A running Gateway mutation uses config.get as its baseline and config.set as its commit instead of writing the file directly.
+  - Saving config while the Gateway is stopped does not start it.
+  - Proxy and other process-launch environment changes still replace the running Gateway process.
+  - Manual restart and health/crash recovery behavior remains available.
+requiredProfiles:
+  - fast
+  - comms
+requiredRules:
+  - openclaw-config-delivery
+  - gateway-readiness-policy
+  - backend-communication-boundary
+  - comms-regression
+  - docs-sync
+requiredTests:
+  - tests/unit/gateway-config-delivery.test.ts
+  - tests/unit/gateway-ws-trace.test.ts
+  - tests/unit/agent-config.test.ts
+  - tests/unit/channel-config.test.ts
+  - tests/unit/host-services.test.ts
+  - tests/unit/provider-runtime-sync.test.ts
+acceptance:
+  - Electron Main owns config delivery; Renderer does not add direct Gateway transport calls.
+  - A running Gateway mutation prefers the runtime-shaped config.get `config` object as its baseline, applies its mutator, and commits the serialized result with config.set and the returned hash as baseHash; source-shaped `raw` is only a compatibility fallback.
+  - Successful config.set delivery does not send SIGUSR1 and does not call GatewayManager.restart.
+  - The coordinator serializes mutations and retries one base-hash conflict from a fresh config.get snapshot.
+  - When Gateway is stopped or starting, the same coordinator performs the mutation against the resolved file path and does not start the Gateway.
+  - A non-conflict RPC mutation failure does not perform an out-of-band file write; it fails the mutation without silently diverging live and persisted config.
+  - Provider deletion, OAuth completion, Agent deletion, Channel save/delete/binding, skill config, image config, and hidden self-heal writes do not carry independent blanket restart policy.
+  - Deleting a custom default Channel account does not recreate its mirrored top-level credentials under a literal `default` account.
+  - Channel account deletion removes matching credentials from plugin-backed account mirrors, including Agent deletion paths.
+  - Gateway WebSocket traces redact serialized raw config-write payloads before logging them.
+  - Proxy environment changes, manual restart, heartbeat recovery, process crash recovery, app update, and app shutdown/startup retain their lifecycle behavior.
+  - No production helper outside the coordinator writes the active OpenClaw config file.
+  - Coordinator file fallback uses the configured OpenClaw config path rather than a hardcoded home-directory path.
+docs:
+  required: true
+---
+
+Use this task spec for the OpenClaw 2026.7.1 config-delivery convergence change.
diff --git a/package.json b/package.json
index 1c14b144..f9c3602c 100644
--- a/package.json
+++ b/package.json
@@ -52,8 +52,10 @@
         "test": "vitest run",
         "test:e2e": "pnpm run build:vite && playwright test",
         "test:e2e:headed": "pnpm run build:vite && playwright test --headed",
+        "perf:chat": "pnpm run build:vite && playwright test tests/e2e/renderer-performance.spec.ts --workers=1",
+        "profile:main": "pnpm run build:vite && electron --inspect=9229 .",
         "harness": "pnpm --filter @clawx/harness start --",
-        "harness:ci": "pnpm harness list && pnpm harness validate --spec harness/specs/scenarios/gateway-backend-communication.md && pnpm harness validate --spec harness/specs/tasks/fix-chat-history-gateway-timeout.example.md --no-diff && pnpm harness run --spec harness/specs/scenarios/gateway-backend-communication.md --dry-run && pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/harness-git.test.ts",
+        "harness:ci": "pnpm harness list && pnpm harness validate --spec harness/specs/scenarios/gateway-backend-communication.md && pnpm harness validate --spec harness/specs/tasks/maintain-session-catalog-reconciliation.example.md --no-diff && pnpm harness run --spec harness/specs/scenarios/gateway-backend-communication.md --dry-run && pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/harness-git.test.ts",
         "comms:replay": "node scripts/comms/replay.mjs",
         "comms:baseline": "node scripts/comms/baseline.mjs",
         "comms:compare": "node scripts/comms/compare.mjs",
@@ -91,6 +93,7 @@
         "@agentclientprotocol/sdk": "1.1.0",
         "electron-store": "^11.0.2",
         "electron-updater": "^6.8.3",
+        "json5": "2.2.3",
         "node-machine-id": "^1.1.12",
         "posthog-node": "^5.28.0",
         "tar": "^6.2.1",
@@ -127,6 +130,9 @@
         "@radix-ui/react-tooltip": "^1.2.8",
         "@sinclair/typebox": "^0.34.48",
         "@soimy/dingtalk": "3.6.6",
+        "@streamdown/cjk": "^1.0.3",
+        "@streamdown/code": "^1.1.1",
+        "@streamdown/math": "^1.0.2",
         "@tencent-connect/qqbot-connector": "^1.1.0",
         "@tencent-weixin/openclaw-weixin": "^2.4.6",
         "@testing-library/jest-dom": "^6.9.1",
@@ -181,15 +187,12 @@
         "react-arborist": "^3.13.2",
         "react-dom": "^19.2.4",
         "react-i18next": "^16.5.4",
-        "react-markdown": "^10.1.0",
         "react-router-dom": "^7.13.0",
-        "rehype-katex": "^7.0.1",
         "remark-frontmatter": "^5.0.0",
-        "remark-gfm": "^4.0.1",
-        "remark-math": "^6.0.0",
         "sharp": "^0.34.5",
         "silk-wasm": "^3.7.1",
         "sonner": "^2.0.7",
+        "streamdown": "^2.5.0",
         "tailwind-merge": "^3.5.0",
         "tailwindcss": "^3.4.19",
         "tailwindcss-animate": "^1.0.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0a6f1a8b..98e2b78b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
       electron-updater:
         specifier: ^6.8.3
         version: 6.8.3
+      json5:
+        specifier: 2.2.3
+        version: 2.2.3
       node-machine-id:
         specifier: ^1.1.12
         version: 1.1.12
@@ -123,6 +126,15 @@ importers:
       '@soimy/dingtalk':
         specifier: 3.6.6
         version: 3.6.6(openclaw@2026.7.1(encoding@0.1.13))
+      '@streamdown/cjk':
+        specifier: ^1.0.3
+        version: 1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5)
+      '@streamdown/code':
+        specifier: ^1.1.1
+        version: 1.1.1(react@19.2.4)
+      '@streamdown/math':
+        specifier: ^1.0.2
+        version: 1.0.2(react@19.2.4)
       '@tencent-connect/qqbot-connector':
         specifier: ^1.1.0
         version: 1.1.0
@@ -285,24 +297,12 @@ importers:
       react-i18next:
         specifier: ^16.5.4
         version: 16.6.6(i18next@25.10.9(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
-      react-markdown:
-        specifier: ^10.1.0
-        version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
       react-router-dom:
         specifier: ^7.13.0
         version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      rehype-katex:
-        specifier: ^7.0.1
-        version: 7.0.1
       remark-frontmatter:
         specifier: ^5.0.0
         version: 5.0.0
-      remark-gfm:
-        specifier: ^4.0.1
-        version: 4.0.1
-      remark-math:
-        specifier: ^6.0.0
-        version: 6.0.0
       sharp:
         specifier: ^0.34.5
         version: 0.34.5
@@ -312,6 +312,9 @@ importers:
       sonner:
         specifier: ^2.0.7
         version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      streamdown:
+        specifier: ^2.5.0
+        version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       tailwind-merge:
         specifier: ^3.5.0
         version: 3.5.0
@@ -379,6 +382,9 @@ packages:
     resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
     engines: {node: '>=10'}
 
+  '@antfu/install-pkg@1.1.0':
+    resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
+
   '@anthropic-ai/sdk@0.109.1':
     resolution: {integrity: sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==}
     hasBin: true
@@ -488,6 +494,9 @@ packages:
   '@borewit/text-codec@0.2.2':
     resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
 
+  '@braintree/sanitize-url@7.1.2':
+    resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
+
   '@bramus/specificity@2.4.2':
     resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
     hasBin: true
@@ -505,6 +514,9 @@ packages:
   '@cacheable/utils@2.4.0':
     resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==}
 
+  '@chevrotain/types@11.1.2':
+    resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
+
   '@clack/core@1.2.0':
     resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
 
@@ -908,6 +920,12 @@ packages:
     resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
     engines: {node: '>=18.18'}
 
+  '@iconify/types@2.0.0':
+    resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
+
+  '@iconify/utils@3.1.4':
+    resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
+
   '@img/colour@1.1.0':
     resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
     engines: {node: '>=18'}
@@ -1266,6 +1284,9 @@ packages:
     resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
     engines: {node: '>= 10.0.0'}
 
+  '@mermaid-js/parser@1.2.0':
+    resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
+
   '@mistralai/mistralai@2.4.0':
     resolution: {integrity: sha512-t6hCx242MTGolB76CI+17jDtPIe/bzLsMdUTMMoMn9Qo1h02N2G5jQYHmKDGU3X//OgR2wvngTD7tO6tPp5poQ==}
     peerDependencies:
@@ -2190,6 +2211,27 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@shikijs/core@3.23.0':
+    resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
+
+  '@shikijs/engine-javascript@3.23.0':
+    resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
+
+  '@shikijs/engine-oniguruma@3.23.0':
+    resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
+
+  '@shikijs/langs@3.23.0':
+    resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
+
+  '@shikijs/themes@3.23.0':
+    resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
+
+  '@shikijs/types@3.23.0':
+    resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
+
+  '@shikijs/vscode-textmate@10.0.2':
+    resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+
   '@silvia-odwyer/photon-node@0.3.4':
     resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==}
 
@@ -2308,6 +2350,21 @@ packages:
   '@standard-schema/spec@1.1.0':
     resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
 
+  '@streamdown/cjk@1.0.3':
+    resolution: {integrity: sha512-WRg8HR/gHbBoTgsMd91OKFUClIoDcEFVofJvluvEAyjx3KpU0aGgD9tGDqHkHj14ShoMSkX0IYetWGegTcwIJw==}
+    peerDependencies:
+      react: ^18.0.0 || ^19.0.0
+
+  '@streamdown/code@1.1.1':
+    resolution: {integrity: sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==}
+    peerDependencies:
+      react: ^18.0.0 || ^19.0.0
+
+  '@streamdown/math@1.0.2':
+    resolution: {integrity: sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g==}
+    peerDependencies:
+      react: ^18.0.0 || ^19.0.0
+
   '@szmarczak/http-timer@4.0.6':
     resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
     engines: {node: '>=10'}
@@ -2387,6 +2444,99 @@ packages:
   '@types/chai@5.2.3':
     resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
 
+  '@types/d3-array@3.2.2':
+    resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+  '@types/d3-axis@3.0.6':
+    resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
+
+  '@types/d3-brush@3.0.6':
+    resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
+
+  '@types/d3-chord@3.0.6':
+    resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
+
+  '@types/d3-color@3.1.3':
+    resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+  '@types/d3-contour@3.0.6':
+    resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
+
+  '@types/d3-delaunay@6.0.4':
+    resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
+
+  '@types/d3-dispatch@3.0.7':
+    resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
+
+  '@types/d3-drag@3.0.7':
+    resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+
+  '@types/d3-dsv@3.0.7':
+    resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
+
+  '@types/d3-ease@3.0.2':
+    resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+  '@types/d3-fetch@3.0.7':
+    resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
+
+  '@types/d3-force@3.0.10':
+    resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
+  '@types/d3-format@3.0.4':
+    resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
+
+  '@types/d3-geo@3.1.1':
+    resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
+
+  '@types/d3-hierarchy@3.1.7':
+    resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
+
+  '@types/d3-interpolate@3.0.4':
+    resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+  '@types/d3-path@3.1.1':
+    resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+  '@types/d3-polygon@3.0.2':
+    resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
+
+  '@types/d3-quadtree@3.0.6':
+    resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
+
+  '@types/d3-random@3.0.4':
+    resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==}
+
+  '@types/d3-scale-chromatic@3.1.0':
+    resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
+
+  '@types/d3-scale@4.0.9':
+    resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+  '@types/d3-selection@3.0.11':
+    resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+
+  '@types/d3-shape@3.1.8':
+    resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+  '@types/d3-time-format@4.0.3':
+    resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
+
+  '@types/d3-time@3.0.4':
+    resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+  '@types/d3-timer@3.0.2':
+    resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
+  '@types/d3-transition@3.0.9':
+    resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+
+  '@types/d3-zoom@3.0.8':
+    resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+
+  '@types/d3@7.4.3':
+    resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+
   '@types/debug@4.1.13':
     resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
 
@@ -2409,6 +2559,9 @@ packages:
   '@types/fs-extra@9.0.13':
     resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
 
+  '@types/geojson@7946.0.16':
+    resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
   '@types/hast@3.0.4':
     resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
 
@@ -2536,6 +2689,9 @@ packages:
   '@ungap/structured-clone@1.3.0':
     resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
 
+  '@upsetjs/venn.js@2.0.0':
+    resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
+
   '@vitejs/plugin-react@5.2.0':
     resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
     engines: {node: ^20.19.0 || >=22.12.0}
@@ -3114,6 +3270,10 @@ packages:
     resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
     engines: {node: '>= 6'}
 
+  commander@7.2.0:
+    resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+    engines: {node: '>= 10'}
+
   commander@8.3.0:
     resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
     engines: {node: '>= 12'}
@@ -3166,6 +3326,12 @@ packages:
     resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
     engines: {node: '>= 0.10'}
 
+  cose-base@1.0.3:
+    resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
+
+  cose-base@2.2.0:
+    resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
+
   crc-32@1.2.2:
     resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
     engines: {node: '>=0.8'}
@@ -3217,6 +3383,162 @@ packages:
   curve25519-js@0.0.4:
     resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
 
+  cytoscape-cose-bilkent@4.1.0:
+    resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
+    peerDependencies:
+      cytoscape: ^3.2.0
+
+  cytoscape-fcose@2.2.0:
+    resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==}
+    peerDependencies:
+      cytoscape: ^3.2.0
+
+  cytoscape@3.34.0:
+    resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==}
+    engines: {node: '>=0.10'}
+
+  d3-array@2.12.1:
+    resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==}
+
+  d3-array@3.2.4:
+    resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+    engines: {node: '>=12'}
+
+  d3-axis@3.0.0:
+    resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
+    engines: {node: '>=12'}
+
+  d3-brush@3.0.0:
+    resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
+    engines: {node: '>=12'}
+
+  d3-chord@3.0.1:
+    resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
+    engines: {node: '>=12'}
+
+  d3-color@3.1.0:
+    resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+    engines: {node: '>=12'}
+
+  d3-contour@4.0.2:
+    resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
+    engines: {node: '>=12'}
+
+  d3-delaunay@6.0.4:
+    resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
+    engines: {node: '>=12'}
+
+  d3-dispatch@3.0.1:
+    resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+    engines: {node: '>=12'}
+
+  d3-drag@3.0.0:
+    resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+    engines: {node: '>=12'}
+
+  d3-dsv@3.0.1:
+    resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
+    engines: {node: '>=12'}
+    hasBin: true
+
+  d3-ease@3.0.1:
+    resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+    engines: {node: '>=12'}
+
+  d3-fetch@3.0.1:
+    resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
+    engines: {node: '>=12'}
+
+  d3-force@3.0.0:
+    resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+    engines: {node: '>=12'}
+
+  d3-format@3.1.2:
+    resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+    engines: {node: '>=12'}
+
+  d3-geo@3.1.1:
+    resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
+    engines: {node: '>=12'}
+
+  d3-hierarchy@3.1.2:
+    resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
+    engines: {node: '>=12'}
+
+  d3-interpolate@3.0.1:
+    resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+    engines: {node: '>=12'}
+
+  d3-path@1.0.9:
+    resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
+
+  d3-path@3.1.0:
+    resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+    engines: {node: '>=12'}
+
+  d3-polygon@3.0.1:
+    resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
+    engines: {node: '>=12'}
+
+  d3-quadtree@3.0.1:
+    resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+    engines: {node: '>=12'}
+
+  d3-random@3.0.1:
+    resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
+    engines: {node: '>=12'}
+
+  d3-sankey@0.12.3:
+    resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==}
+
+  d3-scale-chromatic@3.1.0:
+    resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
+    engines: {node: '>=12'}
+
+  d3-scale@4.0.2:
+    resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+    engines: {node: '>=12'}
+
+  d3-selection@3.0.0:
+    resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+    engines: {node: '>=12'}
+
+  d3-shape@1.3.7:
+    resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==}
+
+  d3-shape@3.2.0:
+    resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+    engines: {node: '>=12'}
+
+  d3-time-format@4.1.0:
+    resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+    engines: {node: '>=12'}
+
+  d3-time@3.1.0:
+    resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+    engines: {node: '>=12'}
+
+  d3-timer@3.0.1:
+    resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+    engines: {node: '>=12'}
+
+  d3-transition@3.0.1:
+    resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      d3-selection: 2 - 3
+
+  d3-zoom@3.0.0:
+    resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+    engines: {node: '>=12'}
+
+  d3@7.9.0:
+    resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
+    engines: {node: '>=12'}
+
+  dagre-d3-es@7.0.14:
+    resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==}
+
   data-uri-to-buffer@4.0.1:
     resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
     engines: {node: '>= 12'}
@@ -3225,6 +3547,9 @@ packages:
     resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
     engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
 
+  dayjs@1.11.21:
+    resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
+
   debounce-fn@6.0.0:
     resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
     engines: {node: '>=18'}
@@ -3270,6 +3595,9 @@ packages:
     resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
     engines: {node: '>= 0.4'}
 
+  delaunator@5.1.0:
+    resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
+
   delayed-stream@1.0.0:
     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
     engines: {node: '>=0.4.0'}
@@ -3357,6 +3685,9 @@ packages:
   dompurify@3.2.7:
     resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
 
+  dompurify@3.4.12:
+    resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
+
   domutils@3.2.2:
     resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
 
@@ -3485,6 +3816,9 @@ packages:
     resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
     engines: {node: '>= 0.4'}
 
+  es-toolkit@1.50.0:
+    resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==}
+
   es6-error@4.1.1:
     resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
 
@@ -3920,6 +4254,9 @@ packages:
     resolution: {integrity: sha512-gGVykS5+c5f1tPV97LuU6IDRMawE2NzpwM9pNz58HQ35IZXDnYL3VOLvNzYognPSeBIOSzQXRu5w96V0aY8y8A==}
     engines: {node: ^12.20.0 || >=14.13.1}
 
+  hachure-fill@0.5.2:
+    resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
+
   has-flag@4.0.0:
     resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
     engines: {node: '>=8'}
@@ -3961,9 +4298,21 @@ packages:
   hast-util-parse-selector@4.0.0:
     resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
 
+  hast-util-raw@9.1.0:
+    resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
+
+  hast-util-sanitize@5.0.2:
+    resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
+
+  hast-util-to-html@9.0.5:
+    resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+
   hast-util-to-jsx-runtime@2.3.6:
     resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
 
+  hast-util-to-parse5@8.0.1:
+    resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+
   hast-util-to-text@4.0.2:
     resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
 
@@ -4017,6 +4366,9 @@ packages:
   html-url-attributes@3.0.1:
     resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
 
+  html-void-elements@3.0.0:
+    resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
   htmlparser2@10.1.0:
     resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
 
@@ -4090,6 +4442,9 @@ packages:
   immediate@3.0.6:
     resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
 
+  import-meta-resolve@4.2.0:
+    resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
+
   imurmurhash@0.1.4:
     resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
     engines: {node: '>=0.8.19'}
@@ -4108,6 +4463,13 @@ packages:
   inline-style-parser@0.2.7:
     resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
 
+  internmap@1.0.1:
+    resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
+
+  internmap@2.0.3:
+    resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+    engines: {node: '>=12'}
+
   ip-address@10.1.0:
     resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
     engines: {node: '>= 12'}
@@ -4290,10 +4652,19 @@ packages:
   keyv@5.6.0:
     resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
 
+  khroma@2.1.0:
+    resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
+
   kysely@0.29.2:
     resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==}
     engines: {node: '>=22.0.0'}
 
+  layout-base@1.0.2:
+    resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
+
+  layout-base@2.0.1:
+    resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
+
   lazy-val@1.0.5:
     resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
 
@@ -4332,6 +4703,9 @@ packages:
     resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
     engines: {node: '>=10'}
 
+  lodash-es@4.18.1:
+    resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
+
   lodash.escaperegexp@4.1.2:
     resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
 
@@ -4411,6 +4785,16 @@ packages:
     engines: {node: '>= 18'}
     hasBin: true
 
+  marked@16.4.2:
+    resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
+    engines: {node: '>= 20'}
+    hasBin: true
+
+  marked@17.0.6:
+    resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
+    engines: {node: '>= 20'}
+    hasBin: true
+
   marked@18.0.5:
     resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
     engines: {node: '>= 20'}
@@ -4472,6 +4856,24 @@ packages:
   mdast-util-to-hast@13.2.1:
     resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
 
+  mdast-util-to-markdown-cjk-friendly-gfm-strikethrough@1.0.0:
+    resolution: {integrity: sha512-1ePVfB4P/vz3xSsm6H3D32r6VYGErxclnuLLFK02/2ReF+UdEKm7caulK6Vm0LBIp5gPRtB2Z1OYDznCkX3k2w==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/mdast': '*'
+    peerDependenciesMeta:
+      '@types/mdast':
+        optional: true
+
+  mdast-util-to-markdown-cjk-friendly@1.0.0:
+    resolution: {integrity: sha512-BoaAm8mlJ+LAYz0Qs532Y3ciTuQYgBUPZcSFbvC/ZKmEMAKgulw84YvQK1gI34t/vL2euSfuaWlqczkTBgamkw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/mdast': '*'
+    peerDependenciesMeta:
+      '@types/mdast':
+        optional: true
+
   mdast-util-to-markdown@2.1.2:
     resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
 
@@ -4496,9 +4898,41 @@ packages:
     resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
     engines: {node: '>= 8'}
 
+  mermaid@11.16.0:
+    resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==}
+
   micromark-core-commonmark@2.0.3:
     resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
 
+  micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1:
+    resolution: {integrity: sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      micromark: ^4.0.0
+      micromark-util-types: ^2.0.0
+    peerDependenciesMeta:
+      micromark-util-types:
+        optional: true
+
+  micromark-extension-cjk-friendly-util@3.0.1:
+    resolution: {integrity: sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      micromark-util-types: '*'
+    peerDependenciesMeta:
+      micromark-util-types:
+        optional: true
+
+  micromark-extension-cjk-friendly@2.0.1:
+    resolution: {integrity: sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      micromark: ^4.0.0
+      micromark-util-types: ^2.0.0
+    peerDependenciesMeta:
+      micromark-util-types:
+        optional: true
+
   micromark-extension-frontmatter@2.0.0:
     resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
 
@@ -4855,6 +5289,12 @@ packages:
     resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
     engines: {node: '>=6'}
 
+  oniguruma-parser@0.12.2:
+    resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
+
+  oniguruma-to-es@4.3.6:
+    resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
+
   openai@6.45.0:
     resolution: {integrity: sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==}
     peerDependencies:
@@ -4940,6 +5380,9 @@ packages:
   package-json-from-dist@1.0.1:
     resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
 
+  package-manager-detector@1.8.0:
+    resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
+
   pako@1.0.11:
     resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
 
@@ -4968,6 +5411,9 @@ packages:
   partial-json@0.1.7:
     resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
 
+  path-data-parser@0.1.0:
+    resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
+
   path-exists@4.0.0:
     resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
     engines: {node: '>=8'}
@@ -5095,6 +5541,12 @@ packages:
     resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
     engines: {node: '>=14.19.0'}
 
+  points-on-curve@0.2.0:
+    resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
+
+  points-on-path@0.2.1:
+    resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
+
   postcss-import@15.1.0:
     resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
     engines: {node: '>=14.0.0'}
@@ -5335,12 +5787,6 @@ packages:
   react-is@17.0.2:
     resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
 
-  react-markdown@10.1.0:
-    resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
-    peerDependencies:
-      '@types/react': '>=18'
-      react: '>=18'
-
   react-refresh@0.18.0:
     resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
     engines: {node: '>=0.10.0'}
@@ -5439,9 +5885,47 @@ packages:
   redux@5.0.1:
     resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
 
+  regex-recursion@6.0.2:
+    resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
+
+  regex-utilities@2.3.0:
+    resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
+
+  regex@6.1.0:
+    resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
+
+  rehype-harden@1.1.8:
+    resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==}
+
   rehype-katex@7.0.1:
     resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
 
+  rehype-raw@7.0.0:
+    resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
+
+  rehype-sanitize@6.0.0:
+    resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
+
+  remark-cjk-friendly-gfm-strikethrough@2.3.1:
+    resolution: {integrity: sha512-JE3TGgouk/sy92SemNMEUhO5mNP4on04cmzOV3s3R5Dbk160ewmpM4tgPiinKKvoJ5UW2fTu7FOYsjVbusSA9w==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/mdast': ^4.0.0
+      unified: ^11.0.0
+    peerDependenciesMeta:
+      '@types/mdast':
+        optional: true
+
+  remark-cjk-friendly@2.3.1:
+    resolution: {integrity: sha512-f+pKZRxCRwNEGFBKNRAZAqU91GIK1SAo3ZyFHWRUgC9zcxRR0BXKd6YwqgSsxtW0rNpUDtONj7H5nje2WL3fcA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/mdast': ^4.0.0
+      unified: ^11.0.0
+    peerDependenciesMeta:
+      '@types/mdast':
+        optional: true
+
   remark-frontmatter@5.0.0:
     resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
 
@@ -5460,6 +5944,9 @@ packages:
   remark-stringify@11.0.0:
     resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
 
+  remend@1.3.0:
+    resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==}
+
   require-directory@2.1.1:
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
     engines: {node: '>=0.10.0'}
@@ -5514,11 +6001,17 @@ packages:
     resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
     engines: {node: '>=8.0'}
 
+  robust-predicates@3.0.3:
+    resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
+
   rollup@4.60.0:
     resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
     engines: {node: '>=18.0.0', npm: '>=8.0.0'}
     hasBin: true
 
+  roughjs@4.6.6:
+    resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
+
   router@2.2.0:
     resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
     engines: {node: '>= 18'}
@@ -5526,6 +6019,9 @@ packages:
   run-parallel@1.2.0:
     resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
 
+  rw@1.3.3:
+    resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+
   safe-buffer@5.1.2:
     resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
 
@@ -5605,6 +6101,9 @@ packages:
     resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
     engines: {node: '>=8'}
 
+  shiki@3.23.0:
+    resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
+
   side-channel-list@1.0.0:
     resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
     engines: {node: '>= 0.4'}
@@ -5759,6 +6258,12 @@ packages:
   std-env@4.0.0:
     resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
 
+  streamdown@2.5.0:
+    resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==}
+    peerDependencies:
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
+
   streamx@2.25.0:
     resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
 
@@ -5810,6 +6315,9 @@ packages:
   style-to-object@1.0.14:
     resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
 
+  stylis@4.4.0:
+    resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
+
   sucrase@3.35.1:
     resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
     engines: {node: '>=16 || 14 >=14.17'}
@@ -5979,6 +6487,10 @@ packages:
     peerDependencies:
       typescript: '>=4.8.4'
 
+  ts-dedent@2.3.0:
+    resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
+    engines: {node: '>=6.10'}
+
   ts-interface-checker@0.1.13:
     resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
 
@@ -6149,6 +6661,10 @@ packages:
   util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 
+  uuid@14.0.1:
+    resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
+    hasBin: true
+
   vary@1.1.2:
     resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
     engines: {node: '>= 0.8'}
@@ -6509,6 +7025,11 @@ snapshots:
 
   '@alloc/quick-lru@5.2.0': {}
 
+  '@antfu/install-pkg@1.1.0':
+    dependencies:
+      package-manager-detector: 1.8.0
+      tinyexec: 1.0.4
+
   '@anthropic-ai/sdk@0.109.1(zod@4.4.3)':
     dependencies:
       json-schema-to-ts: 3.1.1
@@ -6650,6 +7171,8 @@ snapshots:
 
   '@borewit/text-codec@0.2.2': {}
 
+  '@braintree/sanitize-url@7.1.2': {}
+
   '@bramus/specificity@2.4.2':
     dependencies:
       css-tree: 3.2.1
@@ -6692,6 +7215,8 @@ snapshots:
       hashery: 1.5.1
       keyv: 5.6.0
 
+  '@chevrotain/types@11.1.2': {}
+
   '@clack/core@1.2.0':
     dependencies:
       fast-wrap-ansi: 0.1.6
@@ -7099,6 +7624,14 @@ snapshots:
 
   '@humanwhocodes/retry@0.4.3': {}
 
+  '@iconify/types@2.0.0': {}
+
+  '@iconify/utils@3.1.4':
+    dependencies:
+      '@antfu/install-pkg': 1.1.0
+      '@iconify/types': 2.0.0
+      import-meta-resolve: 4.2.0
+
   '@img/colour@1.1.0': {}
 
   '@img/sharp-darwin-arm64@0.34.5':
@@ -7570,6 +8103,10 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@mermaid-js/parser@1.2.0':
+    dependencies:
+      '@chevrotain/types': 11.1.2
+
   '@mistralai/mistralai@2.4.0':
     dependencies:
       '@opentelemetry/semantic-conventions': 1.43.0
@@ -8359,6 +8896,39 @@ snapshots:
   '@rollup/rollup-win32-x64-msvc@4.60.0':
     optional: true
 
+  '@shikijs/core@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+      hast-util-to-html: 9.0.5
+
+  '@shikijs/engine-javascript@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+      '@shikijs/vscode-textmate': 10.0.2
+      oniguruma-to-es: 4.3.6
+
+  '@shikijs/engine-oniguruma@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+      '@shikijs/vscode-textmate': 10.0.2
+
+  '@shikijs/langs@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+
+  '@shikijs/themes@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+
+  '@shikijs/types@3.23.0':
+    dependencies:
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
+  '@shikijs/vscode-textmate@10.0.2': {}
+
   '@silvia-odwyer/photon-node@0.3.4': {}
 
   '@sinclair/typebox@0.34.48': {}
@@ -8454,6 +9024,33 @@ snapshots:
 
   '@standard-schema/spec@1.1.0': {}
 
+  '@streamdown/cjk@1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5)':
+    dependencies:
+      react: 19.2.4
+      remark-cjk-friendly: 2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5)
+      remark-cjk-friendly-gfm-strikethrough: 2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5)
+      unist-util-visit: 5.1.0
+    transitivePeerDependencies:
+      - '@types/mdast'
+      - micromark
+      - micromark-util-types
+      - supports-color
+      - unified
+
+  '@streamdown/code@1.1.1(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+      shiki: 3.23.0
+
+  '@streamdown/math@1.0.2(react@19.2.4)':
+    dependencies:
+      katex: 0.16.45
+      react: 19.2.4
+      rehype-katex: 7.0.1
+      remark-math: 6.0.0
+    transitivePeerDependencies:
+      - supports-color
+
   '@szmarczak/http-timer@4.0.6':
     dependencies:
       defer-to-connect: 2.0.1
@@ -8560,6 +9157,123 @@ snapshots:
       '@types/deep-eql': 4.0.2
       assertion-error: 2.0.1
 
+  '@types/d3-array@3.2.2': {}
+
+  '@types/d3-axis@3.0.6':
+    dependencies:
+      '@types/d3-selection': 3.0.11
+
+  '@types/d3-brush@3.0.6':
+    dependencies:
+      '@types/d3-selection': 3.0.11
+
+  '@types/d3-chord@3.0.6': {}
+
+  '@types/d3-color@3.1.3': {}
+
+  '@types/d3-contour@3.0.6':
+    dependencies:
+      '@types/d3-array': 3.2.2
+      '@types/geojson': 7946.0.16
+
+  '@types/d3-delaunay@6.0.4': {}
+
+  '@types/d3-dispatch@3.0.7': {}
+
+  '@types/d3-drag@3.0.7':
+    dependencies:
+      '@types/d3-selection': 3.0.11
+
+  '@types/d3-dsv@3.0.7': {}
+
+  '@types/d3-ease@3.0.2': {}
+
+  '@types/d3-fetch@3.0.7':
+    dependencies:
+      '@types/d3-dsv': 3.0.7
+
+  '@types/d3-force@3.0.10': {}
+
+  '@types/d3-format@3.0.4': {}
+
+  '@types/d3-geo@3.1.1':
+    dependencies:
+      '@types/geojson': 7946.0.16
+
+  '@types/d3-hierarchy@3.1.7': {}
+
+  '@types/d3-interpolate@3.0.4':
+    dependencies:
+      '@types/d3-color': 3.1.3
+
+  '@types/d3-path@3.1.1': {}
+
+  '@types/d3-polygon@3.0.2': {}
+
+  '@types/d3-quadtree@3.0.6': {}
+
+  '@types/d3-random@3.0.4': {}
+
+  '@types/d3-scale-chromatic@3.1.0': {}
+
+  '@types/d3-scale@4.0.9':
+    dependencies:
+      '@types/d3-time': 3.0.4
+
+  '@types/d3-selection@3.0.11': {}
+
+  '@types/d3-shape@3.1.8':
+    dependencies:
+      '@types/d3-path': 3.1.1
+
+  '@types/d3-time-format@4.0.3': {}
+
+  '@types/d3-time@3.0.4': {}
+
+  '@types/d3-timer@3.0.2': {}
+
+  '@types/d3-transition@3.0.9':
+    dependencies:
+      '@types/d3-selection': 3.0.11
+
+  '@types/d3-zoom@3.0.8':
+    dependencies:
+      '@types/d3-interpolate': 3.0.4
+      '@types/d3-selection': 3.0.11
+
+  '@types/d3@7.4.3':
+    dependencies:
+      '@types/d3-array': 3.2.2
+      '@types/d3-axis': 3.0.6
+      '@types/d3-brush': 3.0.6
+      '@types/d3-chord': 3.0.6
+      '@types/d3-color': 3.1.3
+      '@types/d3-contour': 3.0.6
+      '@types/d3-delaunay': 6.0.4
+      '@types/d3-dispatch': 3.0.7
+      '@types/d3-drag': 3.0.7
+      '@types/d3-dsv': 3.0.7
+      '@types/d3-ease': 3.0.2
+      '@types/d3-fetch': 3.0.7
+      '@types/d3-force': 3.0.10
+      '@types/d3-format': 3.0.4
+      '@types/d3-geo': 3.1.1
+      '@types/d3-hierarchy': 3.1.7
+      '@types/d3-interpolate': 3.0.4
+      '@types/d3-path': 3.1.1
+      '@types/d3-polygon': 3.0.2
+      '@types/d3-quadtree': 3.0.6
+      '@types/d3-random': 3.0.4
+      '@types/d3-scale': 4.0.9
+      '@types/d3-scale-chromatic': 3.1.0
+      '@types/d3-selection': 3.0.11
+      '@types/d3-shape': 3.1.8
+      '@types/d3-time': 3.0.4
+      '@types/d3-time-format': 4.0.3
+      '@types/d3-timer': 3.0.2
+      '@types/d3-transition': 3.0.9
+      '@types/d3-zoom': 3.0.8
+
   '@types/debug@4.1.13':
     dependencies:
       '@types/ms': 2.1.0
@@ -8582,6 +9296,8 @@ snapshots:
     dependencies:
       '@types/node': 25.5.0
 
+  '@types/geojson@7946.0.16': {}
+
   '@types/hast@3.0.4':
     dependencies:
       '@types/unist': 3.0.3
@@ -8744,6 +9460,11 @@ snapshots:
 
   '@ungap/structured-clone@1.3.0': {}
 
+  '@upsetjs/venn.js@2.0.0':
+    optionalDependencies:
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+
   '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0))':
     dependencies:
       '@babel/core': 7.29.0
@@ -9415,6 +10136,8 @@ snapshots:
 
   commander@5.1.0: {}
 
+  commander@7.2.0: {}
+
   commander@8.3.0: {}
 
   commander@9.5.0:
@@ -9458,6 +10181,14 @@ snapshots:
       object-assign: 4.1.1
       vary: 1.1.2
 
+  cose-base@1.0.3:
+    dependencies:
+      layout-base: 1.0.2
+
+  cose-base@2.2.0:
+    dependencies:
+      layout-base: 2.0.1
+
   crc-32@1.2.2: {}
 
   crc@3.8.0:
@@ -9508,6 +10239,190 @@ snapshots:
 
   curve25519-js@0.0.4: {}
 
+  cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0):
+    dependencies:
+      cose-base: 1.0.3
+      cytoscape: 3.34.0
+
+  cytoscape-fcose@2.2.0(cytoscape@3.34.0):
+    dependencies:
+      cose-base: 2.2.0
+      cytoscape: 3.34.0
+
+  cytoscape@3.34.0: {}
+
+  d3-array@2.12.1:
+    dependencies:
+      internmap: 1.0.1
+
+  d3-array@3.2.4:
+    dependencies:
+      internmap: 2.0.3
+
+  d3-axis@3.0.0: {}
+
+  d3-brush@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+
+  d3-chord@3.0.1:
+    dependencies:
+      d3-path: 3.1.0
+
+  d3-color@3.1.0: {}
+
+  d3-contour@4.0.2:
+    dependencies:
+      d3-array: 3.2.4
+
+  d3-delaunay@6.0.4:
+    dependencies:
+      delaunator: 5.1.0
+
+  d3-dispatch@3.0.1: {}
+
+  d3-drag@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-selection: 3.0.0
+
+  d3-dsv@3.0.1:
+    dependencies:
+      commander: 7.2.0
+      iconv-lite: 0.6.3
+      rw: 1.3.3
+
+  d3-ease@3.0.1: {}
+
+  d3-fetch@3.0.1:
+    dependencies:
+      d3-dsv: 3.0.1
+
+  d3-force@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-quadtree: 3.0.1
+      d3-timer: 3.0.1
+
+  d3-format@3.1.2: {}
+
+  d3-geo@3.1.1:
+    dependencies:
+      d3-array: 3.2.4
+
+  d3-hierarchy@3.1.2: {}
+
+  d3-interpolate@3.0.1:
+    dependencies:
+      d3-color: 3.1.0
+
+  d3-path@1.0.9: {}
+
+  d3-path@3.1.0: {}
+
+  d3-polygon@3.0.1: {}
+
+  d3-quadtree@3.0.1: {}
+
+  d3-random@3.0.1: {}
+
+  d3-sankey@0.12.3:
+    dependencies:
+      d3-array: 2.12.1
+      d3-shape: 1.3.7
+
+  d3-scale-chromatic@3.1.0:
+    dependencies:
+      d3-color: 3.1.0
+      d3-interpolate: 3.0.1
+
+  d3-scale@4.0.2:
+    dependencies:
+      d3-array: 3.2.4
+      d3-format: 3.1.2
+      d3-interpolate: 3.0.1
+      d3-time: 3.1.0
+      d3-time-format: 4.1.0
+
+  d3-selection@3.0.0: {}
+
+  d3-shape@1.3.7:
+    dependencies:
+      d3-path: 1.0.9
+
+  d3-shape@3.2.0:
+    dependencies:
+      d3-path: 3.1.0
+
+  d3-time-format@4.1.0:
+    dependencies:
+      d3-time: 3.1.0
+
+  d3-time@3.1.0:
+    dependencies:
+      d3-array: 3.2.4
+
+  d3-timer@3.0.1: {}
+
+  d3-transition@3.0.1(d3-selection@3.0.0):
+    dependencies:
+      d3-color: 3.1.0
+      d3-dispatch: 3.0.1
+      d3-ease: 3.0.1
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-timer: 3.0.1
+
+  d3-zoom@3.0.0:
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+
+  d3@7.9.0:
+    dependencies:
+      d3-array: 3.2.4
+      d3-axis: 3.0.0
+      d3-brush: 3.0.0
+      d3-chord: 3.0.1
+      d3-color: 3.1.0
+      d3-contour: 4.0.2
+      d3-delaunay: 6.0.4
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-dsv: 3.0.1
+      d3-ease: 3.0.1
+      d3-fetch: 3.0.1
+      d3-force: 3.0.0
+      d3-format: 3.1.2
+      d3-geo: 3.1.1
+      d3-hierarchy: 3.1.2
+      d3-interpolate: 3.0.1
+      d3-path: 3.1.0
+      d3-polygon: 3.0.1
+      d3-quadtree: 3.0.1
+      d3-random: 3.0.1
+      d3-scale: 4.0.2
+      d3-scale-chromatic: 3.1.0
+      d3-selection: 3.0.0
+      d3-shape: 3.2.0
+      d3-time: 3.1.0
+      d3-time-format: 4.1.0
+      d3-timer: 3.0.1
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+      d3-zoom: 3.0.0
+
+  dagre-d3-es@7.0.14:
+    dependencies:
+      d3: 7.9.0
+      lodash-es: 4.18.1
+
   data-uri-to-buffer@4.0.1: {}
 
   data-urls@7.0.0(@noble/hashes@2.0.1):
@@ -9517,6 +10432,8 @@ snapshots:
     transitivePeerDependencies:
       - '@noble/hashes'
 
+  dayjs@1.11.21: {}
+
   debounce-fn@6.0.0:
     dependencies:
       mimic-function: 5.0.1
@@ -9559,6 +10476,10 @@ snapshots:
       object-keys: 1.1.1
     optional: true
 
+  delaunator@5.1.0:
+    dependencies:
+      robust-predicates: 3.0.3
+
   delayed-stream@1.0.0: {}
 
   depd@2.0.0: {}
@@ -9660,6 +10581,10 @@ snapshots:
     optionalDependencies:
       '@types/trusted-types': 2.0.7
 
+  dompurify@3.4.12:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+
   domutils@3.2.2:
     dependencies:
       dom-serializer: 2.0.0
@@ -9822,6 +10747,8 @@ snapshots:
       has-tostringtag: 1.0.2
       hasown: 2.0.2
 
+  es-toolkit@1.50.0: {}
+
   es6-error@4.1.1:
     optional: true
 
@@ -10390,6 +11317,8 @@ snapshots:
       - encoding
       - supports-color
 
+  hachure-fill@0.5.2: {}
+
   has-flag@4.0.0: {}
 
   has-property-descriptors@1.0.2:
@@ -10452,6 +11381,42 @@ snapshots:
     dependencies:
       '@types/hast': 3.0.4
 
+  hast-util-raw@9.1.0:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      '@ungap/structured-clone': 1.3.0
+      hast-util-from-parse5: 8.0.3
+      hast-util-to-parse5: 8.0.1
+      html-void-elements: 3.0.0
+      mdast-util-to-hast: 13.2.1
+      parse5: 7.3.0
+      unist-util-position: 5.0.0
+      unist-util-visit: 5.1.0
+      vfile: 6.0.3
+      web-namespaces: 2.0.1
+      zwitch: 2.0.4
+
+  hast-util-sanitize@5.0.2:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@ungap/structured-clone': 1.3.0
+      unist-util-position: 5.0.0
+
+  hast-util-to-html@9.0.5:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      ccount: 2.0.1
+      comma-separated-tokens: 2.0.3
+      hast-util-whitespace: 3.0.0
+      html-void-elements: 3.0.0
+      mdast-util-to-hast: 13.2.1
+      property-information: 7.1.0
+      space-separated-tokens: 2.0.2
+      stringify-entities: 4.0.4
+      zwitch: 2.0.4
+
   hast-util-to-jsx-runtime@2.3.6:
     dependencies:
       '@types/estree': 1.0.8
@@ -10472,6 +11437,16 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  hast-util-to-parse5@8.0.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      comma-separated-tokens: 2.0.3
+      devlop: 1.1.0
+      property-information: 7.1.0
+      space-separated-tokens: 2.0.2
+      web-namespaces: 2.0.1
+      zwitch: 2.0.4
+
   hast-util-to-text@4.0.2:
     dependencies:
       '@types/hast': 3.0.4
@@ -10531,6 +11506,8 @@ snapshots:
 
   html-url-attributes@3.0.1: {}
 
+  html-void-elements@3.0.0: {}
+
   htmlparser2@10.1.0:
     dependencies:
       domelementtype: 2.3.0
@@ -10611,6 +11588,8 @@ snapshots:
 
   immediate@3.0.6: {}
 
+  import-meta-resolve@4.2.0: {}
+
   imurmurhash@0.1.4: {}
 
   indent-string@4.0.0: {}
@@ -10624,6 +11603,10 @@ snapshots:
 
   inline-style-parser@0.2.7: {}
 
+  internmap@1.0.1: {}
+
+  internmap@2.0.3: {}
+
   ip-address@10.1.0: {}
 
   ipaddr.js@1.9.1: {}
@@ -10830,8 +11813,14 @@ snapshots:
     dependencies:
       '@keyv/serialize': 1.1.1
 
+  khroma@2.1.0: {}
+
   kysely@0.29.2: {}
 
+  layout-base@1.0.2: {}
+
+  layout-base@2.0.1: {}
+
   lazy-val@1.0.5: {}
 
   levn@0.4.1:
@@ -10868,6 +11857,8 @@ snapshots:
     dependencies:
       p-locate: 5.0.0
 
+  lodash-es@4.18.1: {}
+
   lodash.escaperegexp@4.1.2: {}
 
   lodash.identity@3.0.0: {}
@@ -10952,6 +11943,10 @@ snapshots:
 
   marked@14.0.0: {}
 
+  marked@16.4.2: {}
+
+  marked@17.0.6: {}
+
   marked@18.0.5: {}
 
   matcher@3.0.0:
@@ -11123,6 +12118,28 @@ snapshots:
       unist-util-visit: 5.1.0
       vfile: 6.0.3
 
+  mdast-util-to-markdown-cjk-friendly-gfm-strikethrough@1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2):
+    dependencies:
+      mdast-util-gfm-strikethrough: 2.0.0
+      mdast-util-to-markdown: 2.1.2
+      micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2)
+      micromark-util-symbol: 2.0.1
+    optionalDependencies:
+      '@types/mdast': 4.0.4
+    transitivePeerDependencies:
+      - micromark-util-types
+      - supports-color
+
+  mdast-util-to-markdown-cjk-friendly@1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2):
+    dependencies:
+      mdast-util-to-markdown: 2.1.2
+      micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2)
+      micromark-util-symbol: 2.0.1
+    optionalDependencies:
+      '@types/mdast': 4.0.4
+    transitivePeerDependencies:
+      - micromark-util-types
+
   mdast-util-to-markdown@2.1.2:
     dependencies:
       '@types/mdast': 4.0.4
@@ -11149,6 +12166,30 @@ snapshots:
 
   merge2@1.4.1: {}
 
+  mermaid@11.16.0:
+    dependencies:
+      '@braintree/sanitize-url': 7.1.2
+      '@iconify/utils': 3.1.4
+      '@mermaid-js/parser': 1.2.0
+      '@types/d3': 7.4.3
+      '@upsetjs/venn.js': 2.0.0
+      cytoscape: 3.34.0
+      cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0)
+      cytoscape-fcose: 2.2.0(cytoscape@3.34.0)
+      d3: 7.9.0
+      d3-sankey: 0.12.3
+      dagre-d3-es: 7.0.14
+      dayjs: 1.11.21
+      dompurify: 3.4.12
+      es-toolkit: 1.50.0
+      katex: 0.16.45
+      khroma: 2.1.0
+      marked: 16.4.2
+      roughjs: 4.6.6
+      stylis: 4.4.0
+      ts-dedent: 2.3.0
+      uuid: 14.0.1
+
   micromark-core-commonmark@2.0.3:
     dependencies:
       decode-named-character-reference: 1.3.0
@@ -11168,6 +12209,38 @@ snapshots:
       micromark-util-symbol: 2.0.1
       micromark-util-types: 2.0.2
 
+  micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2):
+    dependencies:
+      devlop: 1.1.0
+      get-east-asian-width: 1.6.0
+      micromark: 4.0.2
+      micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2)
+      micromark-util-character: 2.1.1
+      micromark-util-chunked: 2.0.1
+      micromark-util-resolve-all: 2.0.1
+      micromark-util-symbol: 2.0.1
+    optionalDependencies:
+      micromark-util-types: 2.0.2
+
+  micromark-extension-cjk-friendly-util@3.0.1(micromark-util-types@2.0.2):
+    dependencies:
+      get-east-asian-width: 1.6.0
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+    optionalDependencies:
+      micromark-util-types: 2.0.2
+
+  micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2):
+    dependencies:
+      devlop: 1.1.0
+      micromark: 4.0.2
+      micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2)
+      micromark-util-chunked: 2.0.1
+      micromark-util-resolve-all: 2.0.1
+      micromark-util-symbol: 2.0.1
+    optionalDependencies:
+      micromark-util-types: 2.0.2
+
   micromark-extension-frontmatter@2.0.0:
     dependencies:
       fault: 2.0.1
@@ -11613,6 +12686,14 @@ snapshots:
     dependencies:
       mimic-fn: 2.1.0
 
+  oniguruma-parser@0.12.2: {}
+
+  oniguruma-to-es@4.3.6:
+    dependencies:
+      oniguruma-parser: 0.12.2
+      regex: 6.1.0
+      regex-recursion: 6.0.2
+
   openai@6.45.0(ws@8.21.0)(zod@4.4.3):
     optionalDependencies:
       ws: 8.21.0
@@ -11757,6 +12838,8 @@ snapshots:
 
   package-json-from-dist@1.0.1: {}
 
+  package-manager-detector@1.8.0: {}
+
   pako@1.0.11: {}
 
   parse-bmfont-ascii@1.0.6:
@@ -11793,6 +12876,8 @@ snapshots:
 
   partial-json@0.1.7: {}
 
+  path-data-parser@0.1.0: {}
+
   path-exists@4.0.0: {}
 
   path-expression-matcher@1.5.0: {}
@@ -11897,6 +12982,13 @@ snapshots:
   pngjs@7.0.0:
     optional: true
 
+  points-on-curve@0.2.0: {}
+
+  points-on-path@0.2.1:
+    dependencies:
+      path-data-parser: 0.1.0
+      points-on-curve: 0.2.0
+
   postcss-import@15.1.0(postcss@8.5.8):
     dependencies:
       postcss: 8.5.8
@@ -12119,24 +13211,6 @@ snapshots:
 
   react-is@17.0.2: {}
 
-  react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
-    dependencies:
-      '@types/hast': 3.0.4
-      '@types/mdast': 4.0.4
-      '@types/react': 19.2.14
-      devlop: 1.1.0
-      hast-util-to-jsx-runtime: 2.3.6
-      html-url-attributes: 3.0.1
-      mdast-util-to-hast: 13.2.1
-      react: 19.2.4
-      remark-parse: 11.0.0
-      remark-rehype: 11.1.2
-      unified: 11.0.5
-      unist-util-visit: 5.1.0
-      vfile: 6.0.3
-    transitivePeerDependencies:
-      - supports-color
-
   react-refresh@0.18.0: {}
 
   react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
@@ -12234,6 +13308,20 @@ snapshots:
 
   redux@5.0.1: {}
 
+  regex-recursion@6.0.2:
+    dependencies:
+      regex-utilities: 2.3.0
+
+  regex-utilities@2.3.0: {}
+
+  regex@6.1.0:
+    dependencies:
+      regex-utilities: 2.3.0
+
+  rehype-harden@1.1.8:
+    dependencies:
+      unist-util-visit: 5.1.0
+
   rehype-katex@7.0.1:
     dependencies:
       '@types/hast': 3.0.4
@@ -12244,6 +13332,40 @@ snapshots:
       unist-util-visit-parents: 6.0.2
       vfile: 6.0.3
 
+  rehype-raw@7.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+      hast-util-raw: 9.1.0
+      vfile: 6.0.3
+
+  rehype-sanitize@6.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+      hast-util-sanitize: 5.0.2
+
+  remark-cjk-friendly-gfm-strikethrough@2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5):
+    dependencies:
+      mdast-util-to-markdown-cjk-friendly-gfm-strikethrough: 1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2)
+      micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2)
+      unified: 11.0.5
+    optionalDependencies:
+      '@types/mdast': 4.0.4
+    transitivePeerDependencies:
+      - micromark
+      - micromark-util-types
+      - supports-color
+
+  remark-cjk-friendly@2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5):
+    dependencies:
+      mdast-util-to-markdown-cjk-friendly: 1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2)
+      micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2)
+      unified: 11.0.5
+    optionalDependencies:
+      '@types/mdast': 4.0.4
+    transitivePeerDependencies:
+      - micromark
+      - micromark-util-types
+
   remark-frontmatter@5.0.0:
     dependencies:
       '@types/mdast': 4.0.4
@@ -12296,6 +13418,8 @@ snapshots:
       mdast-util-to-markdown: 2.1.2
       unified: 11.0.5
 
+  remend@1.3.0: {}
+
   require-directory@2.1.1: {}
 
   require-from-string@2.0.2: {}
@@ -12345,6 +13469,8 @@ snapshots:
       sprintf-js: 1.1.3
     optional: true
 
+  robust-predicates@3.0.3: {}
+
   rollup@4.60.0:
     dependencies:
       '@types/estree': 1.0.8
@@ -12376,6 +13502,13 @@ snapshots:
       '@rollup/rollup-win32-x64-msvc': 4.60.0
       fsevents: 2.3.3
 
+  roughjs@4.6.6:
+    dependencies:
+      hachure-fill: 0.5.2
+      path-data-parser: 0.1.0
+      points-on-curve: 0.2.0
+      points-on-path: 0.2.1
+
   router@2.2.0:
     dependencies:
       debug: 4.4.3
@@ -12390,6 +13523,8 @@ snapshots:
     dependencies:
       queue-microtask: 1.2.3
 
+  rw@1.3.3: {}
+
   safe-buffer@5.1.2: {}
 
   safe-buffer@5.2.1: {}
@@ -12494,6 +13629,17 @@ snapshots:
 
   shebang-regex@3.0.0: {}
 
+  shiki@3.23.0:
+    dependencies:
+      '@shikijs/core': 3.23.0
+      '@shikijs/engine-javascript': 3.23.0
+      '@shikijs/engine-oniguruma': 3.23.0
+      '@shikijs/langs': 3.23.0
+      '@shikijs/themes': 3.23.0
+      '@shikijs/types': 3.23.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
   side-channel-list@1.0.0:
     dependencies:
       es-errors: 1.3.0
@@ -12646,6 +13792,29 @@ snapshots:
 
   std-env@4.0.0: {}
 
+  streamdown@2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+    dependencies:
+      clsx: 2.1.1
+      hast-util-to-jsx-runtime: 2.3.6
+      html-url-attributes: 3.0.1
+      marked: 17.0.6
+      mermaid: 11.16.0
+      react: 19.2.4
+      react-dom: 19.2.4(react@19.2.4)
+      rehype-harden: 1.1.8
+      rehype-raw: 7.0.0
+      rehype-sanitize: 6.0.0
+      remark-gfm: 4.0.1
+      remark-parse: 11.0.0
+      remark-rehype: 11.1.2
+      remend: 1.3.0
+      tailwind-merge: 3.5.0
+      unified: 11.0.5
+      unist-util-visit: 5.1.0
+      unist-util-visit-parents: 6.0.2
+    transitivePeerDependencies:
+      - supports-color
+
   streamx@2.25.0:
     dependencies:
       events-universal: 1.0.1
@@ -12712,6 +13881,8 @@ snapshots:
     dependencies:
       inline-style-parser: 0.2.7
 
+  stylis@4.4.0: {}
+
   sucrase@3.35.1:
     dependencies:
       '@jridgewell/gen-mapping': 0.3.13
@@ -12926,6 +14097,8 @@ snapshots:
     dependencies:
       typescript: 5.9.3
 
+  ts-dedent@2.3.0: {}
+
   ts-interface-checker@0.1.13: {}
 
   tslib@2.8.1: {}
@@ -13081,6 +14254,8 @@ snapshots:
 
   util-deprecate@1.0.2: {}
 
+  uuid@14.0.1: {}
+
   vary@1.1.2: {}
 
   verror@1.10.1:
diff --git a/shared/acp-chat/contract-assertions.ts b/shared/acp-chat/contract-assertions.ts
index 163eb471..ea41a50b 100644
--- a/shared/acp-chat/contract-assertions.ts
+++ b/shared/acp-chat/contract-assertions.ts
@@ -3,7 +3,6 @@ import type { HostEventContract } from '../host-events/contract';
 import type { AcpSessionUpdateEnvelope } from './types';
 
 export const acpChatHostApiActions = [
-  'sendWithMedia',
   'loadAcpSession',
   'sendAcpPrompt',
   'cancelAcpSession',
diff --git a/shared/chat/types.ts b/shared/chat/types.ts
index 06dfcc21..0d25da72 100644
--- a/shared/chat/types.ts
+++ b/shared/chat/types.ts
@@ -1,6 +1,4 @@
-import type { ChatRuntimeEvent } from '../chat-runtime-events';
-
-/** Metadata for locally-attached files (not from Gateway) */
+/** Metadata for files attached to ACP prompts or projected by bounded ACP media compatibility. */
 export interface AttachedFileMeta {
   fileName: string;
   mimeType: string;
@@ -21,7 +19,7 @@ export interface AttachedFileMeta {
   gatewayUrl?: string;
 }
 
-/** Raw message from OpenClaw chat.history */
+/** Structured OpenClaw transcript message used by bounded ACP supplements. */
 export interface RawMessage {
   role: 'user' | 'assistant' | 'system' | 'toolresult';
   content: unknown; // string | ContentBlock[]
@@ -48,7 +46,7 @@ export interface RawMessage {
       workspaceDir?: string;
     }>;
   };
-  /** Local-only: file metadata for user-uploaded attachments (not sent to/from Gateway) */
+  /** Renderer metadata for user-selected files included in an ACP prompt. */
   _attachedFiles?: AttachedFileMeta[];
 }
 
@@ -122,56 +120,16 @@ export type LoadSessionsOptions = {
   gatewayGeneration?: number;
 };
 
-export interface ToolStatus {
-  id?: string;
-  toolCallId?: string;
-  name: string;
-  status: 'running' | 'completed' | 'error';
-  durationMs?: number;
-  summary?: string;
-  updatedAt: number;
-}
-
-export interface ChatRuntimeRunState {
-  runId: string;
-  sessionKey?: string;
-  status: 'running' | 'completed' | 'error' | 'aborted';
-  startedAt?: number;
-  endedAt?: number;
-  assistantText: string;
-  thinkingText: string;
-  events: ChatRuntimeEvent[];
-}
-
 export interface DeleteSessionsResult {
   deletedKeys: string[];
   failedKeys: string[];
 }
 
+export type DeleteSessionResult =
+  | { success: true }
+  | { success: false; error: string };
+
 export interface ChatState {
-  // Messages
-  messages: RawMessage[];
-  loading: boolean;
-  loadingMoreHistory: boolean;
-  hasMoreHistory: boolean;
-  error: string | null;
-  runError: string | null;
-  /** Per-session runError text dismissed by the user (sessionKey -> error message). */
-  dismissedRunErrors: Record;
-
-  // Streaming
-  sending: boolean;
-  activeRunId: string | null;
-  streamingText: string;
-  streamingMessage: unknown | null;
-  streamingTools: ToolStatus[];
-  pendingFinal: boolean;
-  lastUserMessageAt: number | null;
-  /** Images collected from tool results, attached to the next assistant message */
-  pendingToolImages: AttachedFileMeta[];
-  runtimeRuns: Record;
-
-  // Sessions
   sessions: ChatSession[];
   currentSessionKey: string;
   currentAgentId: string;
@@ -180,38 +138,15 @@ export interface ChatState {
   /** Last message timestamp (ms) per session key, used for sorting */
   sessionLastActivity: Record;
 
-  // Thinking
-  thinkingLevel: string | null;
-
-  // Actions
   loadSessions: (options?: LoadSessionsOptions) => Promise;
   handleSessionsChanged: (payload: GatewaySessionsChangedPayload) => void;
   switchSession: (key: string) => void;
   selectAcpSession: (key: string, workspacePath?: string) => void;
   newSession: () => void;
   acknowledgeAcpSessionCreated: (key: string, workspacePath?: string, initialPrompt?: string) => void;
-  deleteSession: (key: string) => Promise;
+  deleteSession: (key: string) => Promise;
   deleteSessions: (keys: string[]) => Promise;
   renameSession: (key: string, label: string) => Promise;
-  cleanupEmptySession: () => void;
-  loadHistory: (quiet?: boolean) => Promise;
-  loadMoreHistory: () => Promise;
-  sendMessage: (
-    text: string,
-    attachments?: Array<{
-      fileName: string;
-      mimeType: string;
-      fileSize: number;
-      stagedPath: string;
-      preview: string | null;
-    }>,
-    targetAgentId?: string | null,
-  ) => Promise;
-  abortRun: () => Promise;
-  handleChatEvent: (event: Record) => void;
-  handleRuntimeEvent: (event: ChatRuntimeEvent) => void;
-  refresh: () => Promise;
-  clearError: () => void;
 }
 
 export const DEFAULT_CANONICAL_PREFIX = 'agent:main';
diff --git a/shared/host-api/contract.ts b/shared/host-api/contract.ts
index c89b58cc..9b95fe36 100644
--- a/shared/host-api/contract.ts
+++ b/shared/host-api/contract.ts
@@ -717,18 +717,6 @@ export type SessionSummariesResult = HostSuccess & {
 export type SessionDeletePayload = { id: string };
 export type SessionRenamePayload = { id: string; title: string };
 
-export type ChatMediaItem = { filePath: string; mimeType?: string; fileName?: string };
-export type ChatSendWithMediaPayload = {
-  sessionKey: string;
-  message?: string;
-  deliver?: boolean;
-  idempotencyKey: string;
-  media?: ChatMediaItem[];
-};
-export type ChatSendWithMediaResult = HostSuccess & {
-  result?: { runId?: string };
-};
-
 export type CronUpdatePayload = { id: string; input: CronJobUpdateInput };
 export type CronIdPayload = { id: string };
 export type CronTogglePayload = CronIdPayload & { enabled: boolean };
@@ -987,7 +975,6 @@ export type HostApiContract = {
     turnTimings: (payload: SessionTurnTimingsPayload) => SessionTurnTimingsResult;
   };
   chat: {
-    sendWithMedia: (payload: ChatSendWithMediaPayload) => ChatSendWithMediaResult;
     loadAcpSession: (payload: AcpChatLoadPayload) => AcpChatOperationResult;
     sendAcpPrompt: (payload: AcpChatPromptPayload) => AcpChatOperationResult;
     cancelAcpSession: (payload: AcpChatCancelPayload) => AcpChatOperationResult;
diff --git a/shared/i18n/locales/en/chat.json b/shared/i18n/locales/en/chat.json
index 880bd575..0016dd26 100644
--- a/shared/i18n/locales/en/chat.json
+++ b/shared/i18n/locales/en/chat.json
@@ -12,19 +12,13 @@
     },
     "noLogs": "(No logs available yet)",
     "scrollToLatest": "Back to bottom",
-    "loadMoreHistory": "Load earlier messages",
-    "loadingMoreHistory": "Loading more…",
     "toolbar": {
         "refresh": "Refresh chat",
         "currentAgent": "Talking to {{agent}}",
         "workspace": "Workspace"
     },
     "generatedFiles": {
-        "title": "File changes ({{count}})",
-        "created": "Created",
-        "modified": "Modified",
-        "openFolder": "Open folder",
-        "viewAll": "View all changes"
+        "openFolder": "Open folder"
     },
     "fileActivity": {
         "created": "Created",
@@ -111,9 +105,7 @@
     "filePreview": {
         "tabs": {
             "source": "Source",
-            "preview": "Preview",
-            "changes": "Changes",
-            "info": "Info"
+            "preview": "Preview"
         },
         "actions": {
             "save": "Save",
@@ -127,11 +119,6 @@
             "openHtmlExternally": "Open HTML in system browser",
             "retry": "Retry"
         },
-        "info": {
-            "path": "Path",
-            "size": "Size",
-            "type": "Type"
-        },
         "toast": {
             "saved": "Saved to disk",
             "saveFailed": "Save failed: {{error}}"
@@ -146,8 +133,8 @@
             "notFound": "File not found",
             "loadFailed": "Load failed: {{error}}",
             "noPreview": "No preview available for this file",
-            "unsupportedFormatTitle": "This file format is not supported for built-in preview or diff",
-            "unsupportedFormatHint": "Built-in preview and diff currently support directly readable text/Markdown-style files only. Open the file in the file manager instead.",
+            "unsupportedFormatTitle": "This file format is not supported for built-in preview",
+            "unsupportedFormatHint": "Built-in preview currently supports directly readable text/Markdown-style files only. Open the file in the file manager instead.",
             "largeBinaryOpenTitle": "This file is too large for built-in preview",
             "largeBinaryOpenHint": "This file is {{size}}. ClawX cannot preview it inline, but you can confirm and open it with the system default app.",
             "openInFinderFailed": "Could not reveal file",
@@ -158,13 +145,6 @@
         "html": {
             "crashed": "HTML preview stopped"
         },
-        "diff": {
-            "noChanges": "No changes to display",
-            "newFile": "New file — no original to compare",
-            "tooLarge": "File too large — diff view disabled, switch to the Source tab to view the full content",
-            "unavailable": "The current chat did not capture a precise baseline for this file, so no diff is available.",
-            "unavailableHint": "Use the Preview tab for the current file; use Git or another VCS for an exact diff."
-        },
         "confirmOpen": {
             "title": "Open file",
             "message": "Open “{{fileName}}” with the system default app?",
@@ -220,35 +200,8 @@
             "openRootInFinder": "Reveal workspace in file manager"
         }
     },
-    "taskPanel": {
-        "eyebrow": "Run View",
-        "title": "Task Outline",
-        "emptyTitle": "No structured steps yet",
-        "emptyBody": "Once a run starts, ClawX will surface thinking, tool calls, and handoff states here.",
-        "status": {
-            "idle": "Idle",
-            "running_one": "1 active step",
-            "running_other": "{{count}} active steps"
-        },
-        "stepStatus": {
-            "running": "Running",
-            "completed": "Done",
-            "error": "Error"
-        }
-    },
-    "executionGraph": {
-        "title": "Execution Graph",
-        "branchLabel": "branch",
-        "thinkingLabel": "Thinking",
-        "errorLabel": "Error",
-        "imageGenerateLabel": "Image generation",
-        "agentRun": "{{agent}} execution",
-        "collapsedSummary": "{{toolCount}} tool calls · {{processCount}} process messages",
-        "collapseAction": "Collapse execution graph"
-    },
     "imageGeneration": {
         "generating": "Generating image, please wait…",
-        "previewLoading": "Loading image preview…",
         "previewUnavailable": "Image generation completed, but the preview could not be loaded.",
         "generatedReady": "Generated image is ready.",
         "generatedReadyWithMissing": "Generated image is ready. Some images could not be loaded."
diff --git a/shared/i18n/locales/en/common.json b/shared/i18n/locales/en/common.json
index 097487a2..1faa0d0c 100644
--- a/shared/i18n/locales/en/common.json
+++ b/shared/i18n/locales/en/common.json
@@ -43,6 +43,9 @@
         "hide": "Hide",
         "clear": "Clear"
     },
+    "markdown": {
+        "copyCode": "Copy code"
+    },
     "status": {
         "running": "Running",
         "stopped": "Stopped",
diff --git a/shared/i18n/locales/ja/chat.json b/shared/i18n/locales/ja/chat.json
index 3d23f380..9d1a90b7 100644
--- a/shared/i18n/locales/ja/chat.json
+++ b/shared/i18n/locales/ja/chat.json
@@ -12,19 +12,13 @@
     },
     "noLogs": "(ログはまだありません)",
     "scrollToLatest": "最下部に戻る",
-    "loadMoreHistory": "以前のメッセージを読み込む",
-    "loadingMoreHistory": "読み込み中…",
     "toolbar": {
         "refresh": "チャットを更新",
         "currentAgent": "現在の会話相手: {{agent}}",
         "workspace": "ワークスペース"
     },
     "generatedFiles": {
-        "title": "ファイルの変更({{count}} 件)",
-        "created": "新規",
-        "modified": "更新",
-        "openFolder": "フォルダーを開く",
-        "viewAll": "ファイル変更を見る"
+        "openFolder": "フォルダーを開く"
     },
     "fileActivity": {
         "created": "作成済み",
@@ -111,9 +105,7 @@
     "filePreview": {
         "tabs": {
             "source": "ソース",
-            "preview": "プレビュー",
-            "changes": "変更",
-            "info": "情報"
+            "preview": "プレビュー"
         },
         "actions": {
             "save": "保存",
@@ -127,11 +119,6 @@
             "openHtmlExternally": "HTML をシステムブラウザで開く",
             "retry": "再試行"
         },
-        "info": {
-            "path": "パス",
-            "size": "サイズ",
-            "type": "種類"
-        },
         "toast": {
             "saved": "ディスクに保存しました",
             "saveFailed": "保存に失敗しました: {{error}}"
@@ -146,8 +133,8 @@
             "notFound": "ファイルが見つかりません",
             "loadFailed": "読み込みに失敗しました: {{error}}",
             "noPreview": "このファイルにはプレビューがありません",
-            "unsupportedFormatTitle": "このファイル形式は内蔵プレビューまたは差分表示に未対応です",
-            "unsupportedFormatHint": "現在、内蔵プレビューと差分表示は直接読み取れるテキスト/Markdown 系ファイルのみ対応しています。ファイルマネージャーで開いてください。",
+            "unsupportedFormatTitle": "このファイル形式は内蔵プレビューに未対応です",
+            "unsupportedFormatHint": "現在、内蔵プレビューは直接読み取れるテキスト/Markdown 系ファイルのみ対応しています。ファイルマネージャーで開いてください。",
             "largeBinaryOpenTitle": "このファイルは大きいため内蔵プレビューできません",
             "largeBinaryOpenHint": "このファイルは {{size}} です。ClawX では内蔵プレビューできませんが、確認後にシステム既定のアプリで開けます。",
             "openInFinderFailed": "ファイルマネージャーで表示できません",
@@ -158,13 +145,6 @@
         "html": {
             "crashed": "HTML プレビューが停止しました"
         },
-        "diff": {
-            "noChanges": "表示する変更はありません",
-            "newFile": "新規ファイルのため比較対象がありません",
-            "tooLarge": "ファイルが大きすぎるため diff ビューは無効化されました。完全な内容は「ソース」タブで確認してください",
-            "unavailable": "この会話ではこのファイルの正確な変更ベースラインを取得できなかったため、差分を生成できません。",
-            "unavailableHint": "「プレビュー」で現在の内容を確認するか、Git などで正確な差分を参照してください。"
-        },
         "confirmOpen": {
             "title": "ファイルを開く",
             "message": "「{{fileName}}」をシステム既定のアプリで開きますか?",
@@ -220,35 +200,8 @@
             "openRootInFinder": "ルートをファイルマネージャーで表示"
         }
     },
-    "taskPanel": {
-        "eyebrow": "実行ビュー",
-        "title": "タスクの流れ",
-        "emptyTitle": "まだ構造化されたステップはありません",
-        "emptyBody": "実行が始まると、ClawX は思考・ツール呼び出し・最終化の状態をここに表示します。",
-        "status": {
-            "idle": "待機中",
-            "running_one": "進行中 1 件",
-            "running_other": "進行中 {{count}} 件"
-        },
-        "stepStatus": {
-            "running": "実行中",
-            "completed": "完了",
-            "error": "エラー"
-        }
-    },
-    "executionGraph": {
-        "title": "実行グラフ",
-        "branchLabel": "branch",
-        "thinkingLabel": "考え中",
-        "errorLabel": "エラー",
-        "imageGenerateLabel": "画像生成",
-        "agentRun": "{{agent}} の実行",
-        "collapsedSummary": "ツール呼び出し {{toolCount}} 件 · プロセスメッセージ {{processCount}} 件",
-        "collapseAction": "実行グラフを折りたたむ"
-    },
     "imageGeneration": {
         "generating": "画像を生成しています。しばらくお待ちください…",
-        "previewLoading": "画像プレビューを読み込み中…",
         "previewUnavailable": "画像生成は完了しましたが、プレビューを読み込めませんでした。",
         "generatedReady": "生成された画像の準備ができました。",
         "generatedReadyWithMissing": "生成された画像の準備ができましたが、一部の画像を読み込めませんでした。"
diff --git a/shared/i18n/locales/ja/common.json b/shared/i18n/locales/ja/common.json
index 11422f11..8467793f 100644
--- a/shared/i18n/locales/ja/common.json
+++ b/shared/i18n/locales/ja/common.json
@@ -43,6 +43,9 @@
         "hide": "非表示",
         "clear": "クリア"
     },
+    "markdown": {
+        "copyCode": "コードをコピー"
+    },
     "status": {
         "running": "実行中",
         "stopped": "停止",
diff --git a/shared/i18n/locales/ru/chat.json b/shared/i18n/locales/ru/chat.json
index c9dc6e08..9f154697 100644
--- a/shared/i18n/locales/ru/chat.json
+++ b/shared/i18n/locales/ru/chat.json
@@ -12,19 +12,13 @@
     },
     "noLogs": "(Журналы ещё недоступны)",
     "scrollToLatest": "Вернуться вниз",
-    "loadMoreHistory": "Загрузить предыдущие сообщения",
-    "loadingMoreHistory": "Загрузка…",
     "toolbar": {
         "refresh": "Обновить чат",
         "currentAgent": "Общение с {{agent}}",
         "workspace": "Рабочая область"
     },
     "generatedFiles": {
-        "title": "Изменения файлов ({{count}})",
-        "created": "Создано",
-        "modified": "Изменено",
-        "openFolder": "Открыть папку",
-        "viewAll": "Все изменения"
+        "openFolder": "Открыть папку"
     },
     "fileActivity": {
         "created": "Создан",
@@ -111,9 +105,7 @@
     "filePreview": {
         "tabs": {
             "source": "Исходник",
-            "preview": "Предпросмотр",
-            "changes": "Изменения",
-            "info": "Информация"
+            "preview": "Предпросмотр"
         },
         "actions": {
             "save": "Сохранить",
@@ -127,11 +119,6 @@
             "openHtmlExternally": "Открыть HTML в системном браузере",
             "retry": "Повторить"
         },
-        "info": {
-            "path": "Путь",
-            "size": "Размер",
-            "type": "Тип"
-        },
         "toast": {
             "saved": "Сохранено на диск",
             "saveFailed": "Ошибка сохранения: {{error}}"
@@ -146,8 +133,8 @@
             "notFound": "Файл не найден",
             "loadFailed": "Ошибка загрузки: {{error}}",
             "noPreview": "Для этого файла предпросмотр недоступен",
-            "unsupportedFormatTitle": "Этот формат файла не поддерживает встроенный предпросмотр или diff",
-            "unsupportedFormatHint": "Встроенный предпросмотр и diff сейчас поддерживают только напрямую читаемые текстовые/Markdown-файлы. Откройте файл через файловый менеджер.",
+            "unsupportedFormatTitle": "Этот формат файла не поддерживает встроенный предпросмотр",
+            "unsupportedFormatHint": "Встроенный предпросмотр сейчас поддерживает только напрямую читаемые текстовые/Markdown-файлы. Откройте файл через файловый менеджер.",
             "largeBinaryOpenTitle": "Этот файл слишком большой для встроенного предпросмотра",
             "largeBinaryOpenHint": "Размер файла — {{size}}. ClawX не может показать его встроенно, но вы можете подтвердить открытие в системном приложении по умолчанию.",
             "openInFinderFailed": "Не удалось показать файл",
@@ -158,13 +145,6 @@
         "html": {
             "crashed": "Предпросмотр HTML остановлен"
         },
-        "diff": {
-            "noChanges": "Нет изменений для отображения",
-            "newFile": "Новый файл — нет оригинала для сравнения",
-            "tooLarge": "Файл слишком большой — режим diff отключён, откройте вкладку «Источник» для просмотра содержимого",
-            "unavailable": "В этом чате не удалось зафиксировать точную базовую версию этого файла, поэтому diff недоступен.",
-            "unavailableHint": "Откройте вкладку «Просмотр» для текущего содержимого; точное сравнение — в Git или другой системе."
-        },
         "confirmOpen": {
             "title": "Открыть файл",
             "message": "Открыть «{{fileName}}» в системном приложении по умолчанию?",
@@ -220,35 +200,8 @@
             "openRootInFinder": "Показать корень в файловом менеджере"
         }
     },
-    "taskPanel": {
-        "eyebrow": "Вид выполнения",
-        "title": "Структура задачи",
-        "emptyTitle": "Пока нет структурированных шагов",
-        "emptyBody": "Когда начнётся выполнение, ClawX покажет размышления, вызовы инструментов и состояния передачи здесь.",
-        "status": {
-            "idle": "Бездействует",
-            "running_one": "1 активный шаг",
-            "running_other": "{{count}} активных шагов"
-        },
-        "stepStatus": {
-            "running": "Выполняется",
-            "completed": "Готово",
-            "error": "Ошибка"
-        }
-    },
-    "executionGraph": {
-        "title": "Граф выполнения",
-        "branchLabel": "ветвь",
-        "thinkingLabel": "Думаю",
-        "errorLabel": "Ошибка модели",
-        "imageGenerateLabel": "Генерация изображения",
-        "agentRun": "Выполнение {{agent}}",
-        "collapsedSummary": "Вызовов инструментов: {{toolCount}} · Промежуточных сообщений: {{processCount}}",
-        "collapseAction": "Свернуть граф выполнения"
-    },
     "imageGeneration": {
         "generating": "Изображение генерируется, подождите…",
-        "previewLoading": "Загрузка предпросмотра изображения…",
         "previewUnavailable": "Создание изображения завершено, но предпросмотр загрузить не удалось.",
         "generatedReady": "Сгенерированное изображение готово.",
         "generatedReadyWithMissing": "Сгенерированное изображение готово, но некоторые изображения не удалось загрузить."
diff --git a/shared/i18n/locales/ru/common.json b/shared/i18n/locales/ru/common.json
index 82792087..1754ca0b 100644
--- a/shared/i18n/locales/ru/common.json
+++ b/shared/i18n/locales/ru/common.json
@@ -43,6 +43,9 @@
         "hide": "Скрыть",
         "clear": "Очистить"
     },
+    "markdown": {
+        "copyCode": "Копировать код"
+    },
     "status": {
         "running": "Запущен",
         "stopped": "Остановлен",
diff --git a/shared/i18n/locales/zh/chat.json b/shared/i18n/locales/zh/chat.json
index 1a68d347..71856c1a 100644
--- a/shared/i18n/locales/zh/chat.json
+++ b/shared/i18n/locales/zh/chat.json
@@ -12,19 +12,13 @@
     },
     "noLogs": "(暂无日志)",
     "scrollToLatest": "回到底部",
-    "loadMoreHistory": "加载更早的消息",
-    "loadingMoreHistory": "加载更多中…",
     "toolbar": {
         "refresh": "刷新聊天",
         "currentAgent": "当前对话对象:{{agent}}",
         "workspace": "工作空间"
     },
     "generatedFiles": {
-        "title": "文件变更({{count}} 个)",
-        "created": "新增",
-        "modified": "修改",
-        "openFolder": "打开文件夹",
-        "viewAll": "查看文件变更"
+        "openFolder": "打开文件夹"
     },
     "fileActivity": {
         "created": "已创建",
@@ -111,9 +105,7 @@
     "filePreview": {
         "tabs": {
             "source": "源码",
-            "preview": "预览",
-            "changes": "变更",
-            "info": "信息"
+            "preview": "预览"
         },
         "actions": {
             "save": "保存",
@@ -127,11 +119,6 @@
             "openHtmlExternally": "在系统浏览器中打开 HTML",
             "retry": "重试"
         },
-        "info": {
-            "path": "路径",
-            "size": "大小",
-            "type": "类型"
-        },
         "toast": {
             "saved": "已保存到磁盘",
             "saveFailed": "保存失败:{{error}}"
@@ -146,8 +133,8 @@
             "notFound": "文件不存在",
             "loadFailed": "加载失败:{{error}}",
             "noPreview": "该文件没有预览",
-            "unsupportedFormatTitle": "此文件格式暂不支持内置预览或变更",
-            "unsupportedFormatHint": "当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在文件管理器中打开该文件。",
+            "unsupportedFormatTitle": "此文件格式暂不支持内置预览",
+            "unsupportedFormatHint": "当前仅支持文本/Markdown 等可直接读取的文件进行内置预览。请在文件管理器中打开该文件。",
             "largeBinaryOpenTitle": "该文件较大,暂不支持内置预览",
             "largeBinaryOpenHint": "当前文件为 {{size}},ClawX 不提供内置预览。你可以确认后直接使用系统默认应用打开。",
             "openInFinderFailed": "无法在文件管理器中显示",
@@ -158,13 +145,6 @@
         "html": {
             "crashed": "HTML 预览已停止运行"
         },
-        "diff": {
-            "noChanges": "没有可显示的变更",
-            "newFile": "这是新增文件,无对比内容",
-            "tooLarge": "文件过大,已禁用 diff 视图,请到「源码」标签查看完整内容",
-            "unavailable": "本会话没有抓到这个文件的精确变更基线,无法生成 diff。",
-            "unavailableHint": "可点击顶部「预览」查看当前文件内容;若需精确差异,请在 Git 等工具中对比版本。"
-        },
         "confirmOpen": {
             "title": "打开文件",
             "message": "确认直接打开「{{fileName}}」?",
@@ -220,35 +200,8 @@
             "openRootInFinder": "在文件管理器中显示根目录"
         }
     },
-    "taskPanel": {
-        "eyebrow": "运行视图",
-        "title": "任务脉络",
-        "emptyTitle": "还没有结构化步骤",
-        "emptyBody": "当一次运行开始后,ClawX 会在这里展示思考、工具调用和收尾状态。",
-        "status": {
-            "idle": "空闲",
-            "running_one": "1 个活动步骤",
-            "running_other": "{{count}} 个活动步骤"
-        },
-        "stepStatus": {
-            "running": "进行中",
-            "completed": "完成",
-            "error": "错误"
-        }
-    },
-    "executionGraph": {
-        "title": "执行关系图",
-        "branchLabel": "分支",
-        "thinkingLabel": "思考中",
-        "errorLabel": "模型调用失败",
-        "imageGenerateLabel": "图片生成",
-        "agentRun": "{{agent}} 执行",
-        "collapsedSummary": "{{toolCount}} 个工具调用,{{processCount}} 条过程消息",
-        "collapseAction": "收起执行关系图"
-    },
     "imageGeneration": {
         "generating": "图片生成中,请稍候…",
-        "previewLoading": "正在加载图片预览…",
         "previewUnavailable": "图片生成已完成,但无法加载预览。",
         "generatedReady": "生成的图片已准备好。",
         "generatedReadyWithMissing": "生成的图片已准备好,但有部分图片无法加载。"
diff --git a/shared/i18n/locales/zh/common.json b/shared/i18n/locales/zh/common.json
index d12e644c..7c93aa66 100644
--- a/shared/i18n/locales/zh/common.json
+++ b/shared/i18n/locales/zh/common.json
@@ -43,6 +43,9 @@
         "hide": "隐藏",
         "clear": "清空"
     },
+    "markdown": {
+        "copyCode": "复制代码"
+    },
     "status": {
         "running": "运行中",
         "stopped": "已停止",
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 00000000..76c687ae
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,11 @@
+{
+  "version": 1,
+  "skills": {
+    "streamdown": {
+      "source": "vercel/streamdown",
+      "sourceType": "github",
+      "skillPath": "skills/streamdown/SKILL.md",
+      "computedHash": "c842552c75b80abb74ef8f92481d34684c709884dd2df22c2ab3b183aee582fe"
+    }
+  }
+}
diff --git a/src/components/file-preview/FilePreviewBody.tsx b/src/components/file-preview/FilePreviewBody.tsx
index 80e0ebd7..fdc796e3 100644
--- a/src/components/file-preview/FilePreviewBody.tsx
+++ b/src/components/file-preview/FilePreviewBody.tsx
@@ -2,19 +2,14 @@
  * Inline file preview body.
  *
  * Renders the icon header (file name / path / save / revert) and a
- * minimal tabbed view for a single file.  Documents (Markdown) render
- * only their `preview` tab; code / other text files render `source`.
- * A `diff` tab is added when the file is editable AND there are
- * AI-applied edits to display.  The metadata `info` tab has been
- * intentionally removed — path / size are visible in the header bar.
+ * minimal tabbed view for a single file. Documents (Markdown) render
+ * their `preview` tab; code / other text files render `source`.
  *
- * The `mode` prop narrows the tab set for callers that want a single,
- * fixed view (e.g. the artifact panel's preview tab forces `preview`, and
- * the changes tab's right pane forces `diff`).
+ * The `mode` prop lets callers force a read-only preview surface.
  *
  * Used by:
  *   - `FilePreviewOverlay` for the Skills detail Sheet (read-only).
- *   - `ArtifactPanel`'s ChangesTab and PreviewTab.
+ *   - `ArtifactPanel`'s PreviewTab.
  *
  * All sandbox / read-only / large-file / binary edge cases are handled
  * here so callers only pass a `FilePreviewTarget` and a `readOnly` flag.
@@ -36,10 +31,7 @@ import {
 } from '@/lib/file-preview-client';
 import { getFilePreviewTargetIdentity, type FilePreviewTarget } from './types';
 import { previewDisplayPath } from './build-preview-target';
-import {
-  isHtmlPreviewExt,
-  supportsInlineDiff,
-} from '@/lib/generated-files';
+import { isHtmlPreviewExt } from '@/lib/generated-files';
 import {
   filePreviewKind,
   isFilePreviewWithinSizeLimit,
@@ -57,7 +49,6 @@ import ImageViewer from './ImageViewer';
 import { HtmlPreviewAnchor } from '@/components/web-browser/WebBrowserAnchor';
 
 const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
-const MonacoDiffViewerLazy = lazy(() => import('./MonacoDiffViewer'));
 const PdfViewerLazy = lazy(() => import('./PdfViewer'));
 const SheetViewerLazy = lazy(() => import('./SheetViewer'));
 const DocxViewerLazy = lazy(() => import('./DocxViewer'));
@@ -66,12 +57,10 @@ const PptxViewerLazy = lazy(() => import('./PptxViewer'));
 /**
  * Tab set for the body.
  *
- *   - 'full'    – default: preview / source / diff as appropriate.
- *   - 'preview' – render-only; hides the diff tab and forces read-only.
- *   - 'diff'    – diff-only; the body collapses to a single diff view
- *                 with no tab strip, save/revert, or source toggle.
+ *   - 'full'    – default: preview / source as appropriate.
+ *   - 'preview' – render-only and read-only.
  */
-export type FilePreviewBodyMode = 'full' | 'preview' | 'diff';
+export type FilePreviewBodyMode = 'full' | 'preview';
 
 export interface FilePreviewBodyProps {
   file: FilePreviewTarget;
@@ -84,7 +73,7 @@ export interface FilePreviewBodyProps {
   trailingHeader?: React.ReactNode;
   /** Optional left padding override for headers rendered beneath native window chrome. */
   headerLeftInset?: number;
-  /** Limit the visible tabs.  Default: 'full'. */
+  /** Control whether the surface is editable. Default: 'full'. */
   mode?: FilePreviewBodyMode;
   /** When true, hide the file header (name / path / actions). */
   hideHeader?: boolean;
@@ -102,14 +91,9 @@ type LoadState =
   | { identity: string; status: 'outsideSandbox' }
   | { identity: string; status: 'error'; message: string };
 
-type Tab = 'source' | 'preview' | 'diff';
-
-function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[] {
-  // Diff-only mode short-circuits. Callers (e.g. the changes tab's right
-  // pane) want a pure git-style diff with no tab strip — but only for
-  // formats where inline diff is actually supported.
-  if (mode === 'diff') return supportsInlineDiff(file) ? ['diff'] : [];
+type Tab = 'source' | 'preview';
 
+function tabsForFile(file: FilePreviewTarget): Tab[] {
   const previewKind = filePreviewKind(file);
   if (!previewKind) return [];
   const richPreview = richFilePreviewKind(file);
@@ -127,87 +111,14 @@ function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[]
   } else {
     tabs.push('source');
   }
-  // Diff tab appears in 'full' mode whenever we captured a Write/Edit
-  // payload — read-only is fine, the diff is informational only.
-  if (
-    mode === 'full' &&
-    supportsInlineDiff(file) &&
-    (file.fullContent != null || (file.edits != null && file.edits.length > 0))
-  ) {
-    tabs.push('diff');
-  }
   return tabs;
 }
 
 function pickInitialTab(tabs: Tab[], file: FilePreviewTarget): Tab {
   if (file.contentType === 'document' && tabs.includes('preview')) return 'preview';
-  // For changes view (edited code), prefer the diff tab if present so
-  // the user sees the change immediately on click.
-  if (tabs.includes('diff') && file.contentType !== 'document') return 'diff';
   return tabs[0] ?? 'source';
 }
 
-function normaliseEol(s: string): string {
-  return s.replace(/\r\n/g, '\n');
-}
-
-type DiffPair = {
-  /** Left pane.  `null` makes Monaco render the side as empty (new-file). */
-  oldContent: string | null;
-  /** Right pane. */
-  newContent: string;
-  /**
-   * - `whole`       – right pane is the full file (Write tool).
-   * - `snippet`     – left/right are the joined Edit op old/new strings.
-   * - `unavailable` – the chat captured no payload to diff against.
-   */
-  kind: 'whole' | 'snippet' | 'unavailable';
-};
-
-/** Visual separator between MultiEdit hunks. */
-const SNIPPET_SEPARATOR = '\n\n';
-
-/**
- * Build a diff pair purely from the captured tool payload — never reads
- * disk. Edit tools show the exact old → new snippet swap. Write-family
- * tools prefer the captured baseline when available, so modified files
- * render a true before/after diff instead of a misleading all-green view.
- */
-function computeDiffPair(file: FilePreviewTarget): DiffPair {
-  // Edit / StrReplace / MultiEdit — show the snippet swap directly.
-  if (file.edits && file.edits.length > 0) {
-    const lefts = file.edits.map((op) => normaliseEol(op.old ?? ''));
-    const rights = file.edits.map((op) => normaliseEol(op.new ?? ''));
-    const left = lefts.join(SNIPPET_SEPARATOR);
-    const right = rights.join(SNIPPET_SEPARATOR);
-    if (left || right) {
-      return { oldContent: left, newContent: right, kind: 'snippet' };
-    }
-  }
-
-  if (file.fullContent != null) {
-    if (file.baseline?.status === 'ok') {
-      return {
-        oldContent: normaliseEol(file.baseline.content),
-        newContent: normaliseEol(file.fullContent),
-        kind: 'whole',
-      };
-    }
-    if (file.baseline?.status === 'missing') {
-      return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' };
-    }
-    if (file.baseline?.status === 'unavailable') {
-      return { oldContent: null, newContent: '', kind: 'unavailable' };
-    }
-    if (file.action === 'created') {
-      return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' };
-    }
-    return { oldContent: null, newContent: '', kind: 'unavailable' };
-  }
-
-  return { oldContent: null, newContent: '', kind: 'unavailable' };
-}
-
 export function FilePreviewBody({
   file,
   readOnly = false,
@@ -232,16 +143,14 @@ export function FilePreviewBody({
   const [tab, setTab] = useState('source');
   const [size, setSize] = useState(file.size);
 
-  // Preview / diff modes are read-only by definition — those views are
-  // for inspecting content, not editing it.
-  const enforcedReadOnly = readOnly || !!file.attachmentFileRef || !!file.workspaceFileRef || mode === 'preview' || mode === 'diff';
-  const tabs = useMemo(() => tabsForFile(file, mode), [file, mode]);
-  const unsupportedPreviewFormat = mode !== 'diff' && filePreviewKind(file) == null;
-  const unsupportedDiffFormat = mode === 'diff' && !supportsInlineDiff(file);
+  // Preview mode is for inspecting content, not editing it.
+  const enforcedReadOnly = readOnly || !!file.attachmentFileRef || !!file.workspaceFileRef || mode === 'preview';
+  const tabs = useMemo(() => tabsForFile(file), [file]);
+  const unsupportedPreviewFormat = filePreviewKind(file) == null;
   const richPreview = richFilePreviewKind(file);
   // Binary document previews own their own loading
   // pipeline — we must not pipe them through `readTextFile` (which would
-  // reject them as binary) and the diff tab is intentionally hidden.
+  // reject them as binary).
   const isRichDocumentPreview = richPreview === 'pdf'
     || richPreview === 'sheet'
     || richPreview === 'docx'
@@ -261,16 +170,6 @@ export function FilePreviewBody({
     setTab(pickInitialTab(tabs, file));
     setSize(file.size);
 
-    // Diff-only mode renders entirely from the captured tool payload —
-    // no disk read needed, so we can mark the body "ready" immediately.
-    if (mode === 'diff') {
-      setState({ identity: loadIdentity, status: 'ready', content: '', readOnly: enforcedReadOnly });
-      setDraft(null);
-      return () => {
-        cancelled = true;
-      };
-    }
-
     if (unsupportedPreviewFormat) {
       setState({ identity: loadIdentity, status: 'ready', content: '', readOnly: enforcedReadOnly });
       setDraft(null);
@@ -399,7 +298,7 @@ export function FilePreviewBody({
     return () => {
       cancelled = true;
     };
-  }, [file, loadIdentity, enforcedReadOnly, mode, tabs, unsupportedPreviewFormat, isRichDocumentPreview, richPreview, richPreviewLimitTarget]);
+  }, [file, loadIdentity, enforcedReadOnly, tabs, unsupportedPreviewFormat, isRichDocumentPreview, richPreview, richPreviewLimitTarget]);
 
   const effectiveReadOnly = state.status === 'ready' ? state.readOnly : true;
   const allowSystemActions = !file.attachmentFileRef && !file.workspaceFileRef;
@@ -461,7 +360,7 @@ export function FilePreviewBody({
         

{directOpen ? t('filePreview.errors.largeBinaryOpenTitle', 'This file is too large for inline preview') - : t('filePreview.errors.unsupportedFormatTitle', 'This file format is not supported for inline preview or diff')} + : t('filePreview.errors.unsupportedFormatTitle', 'This file format is not supported for inline preview')}

{allowSystemActions &&

{directOpen @@ -471,7 +370,7 @@ export function FilePreviewBody({ }) : t( 'filePreview.errors.unsupportedFormatHint', - 'Only directly readable files such as text and Markdown support inline preview and diff. Please open this file in your file manager.', + 'Only directly readable files such as text and Markdown support inline preview. Please open this file in your file manager.', )}

} @@ -491,7 +390,7 @@ export function FilePreviewBody({ }; const renderBody = () => { - if (unsupportedPreviewFormat || unsupportedDiffFormat) { + if (unsupportedPreviewFormat) { return renderUnsupportedFormat(); } if (state.status === 'loading') { @@ -701,46 +600,6 @@ export function FilePreviewBody({ )} )} - {tabs.includes('diff') && ( - - - - - } - > - {(() => { - const pair = computeDiffPair(file); - if (pair.kind === 'unavailable') { - return ( -
-

- {t( - 'filePreview.diff.unavailable', - 'This session did not capture an exact baseline for this file, so a diff cannot be generated.', - )} -

-

- {t( - 'filePreview.diff.unavailableHint', - 'Use the Preview tab above to view the current file contents. For an exact diff, compare versions in Git or another external tool.', - )} -

-
- ); - } - return ( - - ); - })()} -
-
- )} ); }; @@ -781,7 +640,6 @@ export function FilePreviewBody({ {id === 'source' && t('filePreview.tabs.source', 'Source')} {id === 'preview' && t('filePreview.tabs.preview', 'Preview')} - {id === 'diff' && t('filePreview.tabs.changes', 'Changes')} ))} diff --git a/src/components/file-preview/GeneratedFilesPanel.tsx b/src/components/file-preview/GeneratedFilesPanel.tsx deleted file mode 100644 index fc4f73d5..00000000 --- a/src/components/file-preview/GeneratedFilesPanel.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Inline panel showing files the AI wrote/edited in the current run. - * Lives directly under the ExecutionGraphCard for each user trigger - * (see Chat/index.tsx). - */ -import { useTranslation } from 'react-i18next'; -import { FolderOpen } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; -import { cn } from '@/lib/utils'; -import { hostApi } from '@/lib/host-api'; -import { - computeLineStats, - supportsInlineDiff, - supportsInlineDocumentPreview, - supportsRichDocumentPreview, - type GeneratedFile, -} from '@/lib/generated-files'; - -export interface GeneratedFilesPanelProps { - files: GeneratedFile[]; - onOpen: (file: GeneratedFile) => void; - onRevealInFileManager?: (file: GeneratedFile) => void; - className?: string; -} - -export function GeneratedFilesPanel({ - files, - onOpen, - onRevealInFileManager, - className, -}: GeneratedFilesPanelProps) { - const { t } = useTranslation('chat'); - - if (!files.length) return null; - - const revealInFileManager = (file: GeneratedFile) => { - if (onRevealInFileManager) { - onRevealInFileManager(file); - return; - } - void hostApi.shell.showItemInFolder(file.filePath); - }; - - return ( -
-
-

- {t('generatedFiles.title', { count: files.length, defaultValue: 'File changes ({{count}})' })} -

-
-
- {files.map((file) => { - const lineStats = computeLineStats(file); - const clickable = supportsInlineDiff(file) || supportsInlineDocumentPreview(file.ext); - const revealOnly = supportsRichDocumentPreview(file.ext); - if (revealOnly) { - return ( - - ); - } - return ( - - ); - })} -
-
- ); -} - -export default GeneratedFilesPanel; diff --git a/src/components/file-preview/MarkdownPreview.tsx b/src/components/file-preview/MarkdownPreview.tsx index 75cb3541..2311403d 100644 --- a/src/components/file-preview/MarkdownPreview.tsx +++ b/src/components/file-preview/MarkdownPreview.tsx @@ -1,26 +1,19 @@ -/** - * Rendered Markdown preview. - * - * Two important deltas from a vanilla ReactMarkdown setup: - * - * 1. `remark-frontmatter` is enabled with the `yaml`/`toml` flavours so a - * file like `SKILL.md` doesn't render its `---\nname: …\n---` block as - * a giant paragraph between two horizontal rules. The frontmatter is - * extracted into a side-rendered metadata card above the body so users - * can still see it without the noise. - * - * 2. We render heading / inline-code / fenced-code with explicit Tailwind - * classes instead of relying solely on `.prose`, because the project - * doesn't ship `@tailwindcss/typography` — only the small subset of - * `.prose` rules from `globals.css` exists. - */ import { useMemo } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; +import { useTranslation } from 'react-i18next'; import remarkFrontmatter from 'remark-frontmatter'; -import rehypeKatex from 'rehype-katex'; +import { + defaultRemarkPlugins, + Streamdown, + type Components, + type StreamdownProps, +} from 'streamdown'; import { BrowserLink } from '@/components/common/BrowserLink'; +import { + streamdownControls, + streamdownLinkSafety, + streamdownPlugins, + streamdownRehypePlugins, +} from '@/components/markdown/streamdown-config'; import { cn } from '@/lib/utils'; export interface MarkdownPreviewProps { @@ -28,90 +21,64 @@ export interface MarkdownPreviewProps { className?: string; } -interface FrontmatterSplit { - body: string; - yaml: string | null; -} +const previewRemarkPlugins: NonNullable = [ + ...Object.values(defaultRemarkPlugins), + [remarkFrontmatter, ['yaml', 'toml']], +]; -function splitFrontmatter(source: string): FrontmatterSplit { - if (!source.startsWith('---')) { - return { body: source, yaml: null }; - } - // Match opening fence + YAML body + closing fence on its own line. - const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); - if (!match) return { body: source, yaml: null }; - const yaml = match[1].trim(); - const body = source.slice(match[0].length); - return { body, yaml: yaml.length > 0 ? yaml : null }; -} +const previewComponents: Components = { + h1: ({ children }) => ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + h4: ({ children }) => ( +

+ {children} +

+ ), + a: ({ children, href }) => href ? ( + + {children} + + ) : <>{children}, + inlineCode: ({ children }) => ( + + {children} + + ), +}; export default function MarkdownPreview({ source, className }: MarkdownPreviewProps) { - const { body, yaml } = useMemo(() => splitFrontmatter(source), [source]); + const { t } = useTranslation('common'); + const translations = useMemo(() => ({ + copyCode: t('markdown.copyCode'), + }), [t]); return ( -
- {yaml && ( -
-          {yaml}
-        
- )} - ( -

- {children} -

- ), - h2: ({ children, ...props }) => ( -

- {children} -

- ), - h3: ({ children, ...props }) => ( -

- {children} -

- ), - h4: ({ children, ...props }) => ( -

- {children} -

- ), - a: ({ children, href }) => href ? ( - - {children} - - ) : <>{children}, - code: ({ className: codeClass, children, ...props }) => { - const match = /language-(\w+)/.exec(codeClass || ''); - const isInline = !match && !codeClass && !String(children).includes('\n'); - if (isInline) { - return ( - - {children} - - ); - } - return ( - - {children} - - ); - }, - pre: ({ children }) => ( -
-              {children}
-            
- ), - }} - > - {body} -
-
+ + {source} + ); } diff --git a/src/components/file-preview/WorkspaceBrowserBody.tsx b/src/components/file-preview/WorkspaceBrowserBody.tsx index 37f43373..4e37a343 100644 --- a/src/components/file-preview/WorkspaceBrowserBody.tsx +++ b/src/components/file-preview/WorkspaceBrowserBody.tsx @@ -775,7 +775,7 @@ export function WorkspaceBrowserBody({ function WorkspaceTreeContainerRow({ attrs, innerRef, children }: RowRendererProps) { return ( -
+
{children}
); diff --git a/src/components/file-preview/types.ts b/src/components/file-preview/types.ts index c19eedf2..6ebc1643 100644 --- a/src/components/file-preview/types.ts +++ b/src/components/file-preview/types.ts @@ -5,7 +5,7 @@ * tree, skills page, …) can import the type without pulling in the Sheet / * Monaco component graph. */ -import type { FileContentType, FileEditOp, GeneratedFileBaseline } from '@/lib/generated-files'; +import type { FileContentType } from '@/lib/generated-files'; import type { AttachmentFileRef, WorkspaceFileRef } from '@/lib/file-preview-client'; export interface FilePreviewTarget { @@ -18,33 +18,8 @@ export interface FilePreviewTarget { contentType: FileContentType; /** Known file size from chat attachment metadata, when available. */ size?: number; - /** - * From chat extraction only. Drives the badge in the changes list and is - * not used by the diff view itself (which derives "before/after" from - * `fullContent` / `edits` directly, WorkBuddy-style). - */ + /** From file activity metadata, when available. */ action?: 'created' | 'modified'; - /** - * Full new content of the file when the tool payload provides it (Write - * family). - */ - fullContent?: string; - /** - * Content of the file *before* the AI's write, captured from disk when - * the tool_use was first detected in the stream. - * - * - `ok` → render a real before/after diff - * - `missing` → render a new-file diff (empty left pane) - * - `unavailable`→ avoid pretending the file was new; show diff unavailable - */ - baseline?: GeneratedFileBaseline; - /** - * Edit operations from Edit / StrReplace / MultiEdit. The diff view - * renders these directly as a snippet diff (left = joined `op.old`, - * right = joined `op.new`) — exactly what the AI changed, no disk - * reads, no reverse-application. - */ - edits?: FileEditOp[]; } export function getFilePreviewTargetIdentity( diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 491e1dbd..aacdf5e5 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -159,7 +159,6 @@ export function Sidebar() { const deleteSessions = useChatStore((s) => s.deleteSessions); const renameSession = useChatStore((s) => s.renameSession); const loadSessions = useChatStore((s) => s.loadSessions); - const loadHistory = useChatStore((s) => s.loadHistory); const sessionAttentionByKey = useSessionAttentionStore((s) => s.bySessionKey); const markRead = useSessionAttentionStore((s) => s.markRead); const handleNewChat = useNewChatAction(); @@ -170,26 +169,10 @@ export function Sidebar() { const gatewayRestarting = isGatewayRestarting(gatewayStatus); const gatewayRuntimeKey = `${gatewayStatus.pid ?? 'none'}:${gatewayStatus.connectedAt ?? 'none'}:${gatewayStatus.port}`; - const hasLoadedCurrentRuntimeRef = useRef(false); - - useEffect(() => { - hasLoadedCurrentRuntimeRef.current = false; - }, [gatewayRuntimeKey]); - useEffect(() => { if (!isGatewayReady) return; - let cancelled = false; - (async () => { - await loadSessions(); - if (cancelled) return; - if (hasLoadedCurrentRuntimeRef.current) return; - hasLoadedCurrentRuntimeRef.current = true; - await loadHistory(false); - })(); - return () => { - cancelled = true; - }; - }, [gatewayRuntimeKey, isGatewayReady, loadHistory, loadSessions]); + void loadSessions(); + }, [gatewayRuntimeKey, isGatewayReady, loadSessions]); const agents = useAgentsStore((s) => s.agents); const fetchAgents = useAgentsStore((s) => s.fetchAgents); @@ -765,9 +748,7 @@ export function Sidebar() { aria-current={isCurrentSession ? 'page' : undefined} onClick={() => { markRead(s.key); - if (currentSessionKey === s.key) { - void loadHistory(false); - } else { + if (currentSessionKey !== s.key) { switchSession(s.key); } navigate('/'); @@ -988,7 +969,8 @@ export function Sidebar() { onConfirm={async () => { const targetSession = sessionToDelete; if (!targetSession) return; - await deleteSession(targetSession.key); + const result = await deleteSession(targetSession.key); + if (!result.success) return; if (currentSessionKey === targetSession.key) navigate('/'); setDeleteDialogOpen(false); }} diff --git a/src/components/markdown/streamdown-config.ts b/src/components/markdown/streamdown-config.ts new file mode 100644 index 00000000..b9596a03 --- /dev/null +++ b/src/components/markdown/streamdown-config.ts @@ -0,0 +1,38 @@ +import { cjk } from '@streamdown/cjk'; +import { createCodePlugin } from '@streamdown/code'; +import { createMathPlugin } from '@streamdown/math'; +import { + defaultRehypePlugins, + type AnimateOptions, + type ControlsConfig, + type LinkSafetyConfig, + type PluginConfig, +} from 'streamdown'; + +export const streamdownPlugins: PluginConfig = { + code: createCodePlugin({ themes: ['github-light', 'github-dark'] }), + math: createMathPlugin({ singleDollarTextMath: true }), + cjk, +}; + +const { raw: omittedRawPlugin, ...safeDefaultRehypePlugins } = defaultRehypePlugins; +void omittedRawPlugin; + +export const streamdownRehypePlugins = Object.values(safeDefaultRehypePlugins); + +export const streamdownLinkSafety = { + enabled: false, +} as const satisfies LinkSafetyConfig; + +export const streamdownControls = { + code: { copy: true, download: false }, + mermaid: false, + table: false, +} as const satisfies ControlsConfig; + +export const streamdownAnimation = { + animation: 'fadeIn', + duration: 140, + stagger: 0, + sep: 'word', +} as const satisfies AnimateOptions; diff --git a/src/lib/acp/timeline-groups.ts b/src/lib/acp/timeline-groups.ts index ca2852ce..71bb57e0 100644 --- a/src/lib/acp/timeline-groups.ts +++ b/src/lib/acp/timeline-groups.ts @@ -40,7 +40,7 @@ function appendUserItem(groups: AcpTimelineDisplayGroup[], item: MessageSegmentI if (previous !== group) groups.push(group); const { attachments, remaining } = extractAttachments(item.parts); - group.items.push({ ...item, parts: remaining }); + group.items.push(remaining === item.parts ? item : { ...item, parts: remaining }); group.attachments.push(...attachments); } @@ -49,11 +49,11 @@ function extractAttachments(parts: RenderPart[]): { remaining: RenderPart[]; } { const attachments: AttachmentRenderPart[] = []; - const remaining: RenderPart[] = []; for (const part of parts) { if (part.kind === 'attachment') attachments.push(part); - else remaining.push(part); } + if (attachments.length === 0) return { attachments, remaining: parts }; + const remaining = parts.filter((part) => part.kind !== 'attachment'); return { attachments, remaining }; } @@ -84,19 +84,19 @@ function appendAssistantItem(groups: AcpTimelineDisplayGroup[], item: TimelineIt if (item.kind === 'message-segment') { const { attachments, remaining } = extractAttachments(item.parts); group.attachments.push(...attachments); - if (remaining.length > 0) group.items.push({ ...item, parts: remaining }); + if (remaining.length > 0) group.items.push(remaining === item.parts ? item : { ...item, parts: remaining }); return; } if (item.kind === 'thought') { const { attachments, remaining } = extractAttachments(item.parts); group.attachments.push(...attachments); - group.items.push({ ...item, parts: remaining }); + group.items.push(remaining === item.parts ? item : { ...item, parts: remaining }); return; } if (item.kind === 'tool-call') { const { attachments, remaining } = extractAttachments(item.outputParts); group.attachments.push(...attachments); - group.items.push({ ...item, outputParts: remaining }); + group.items.push(remaining === item.outputParts ? item : { ...item, outputParts: remaining }); return; } group.items.push(item); diff --git a/src/lib/generated-files.ts b/src/lib/generated-files.ts index 665853fd..7d6228bd 100644 --- a/src/lib/generated-files.ts +++ b/src/lib/generated-files.ts @@ -1,15 +1,3 @@ -/** - * Generated files extraction. - * - * Inspects a run segment of chat messages (the slice from a user trigger - * to its terminating assistant reply) and surfaces files the AI wrote / - * edited via tool calls. Used by `GeneratedFilesPanel` to render inline - * file cards under each run, and by `FilePreviewBody` / `ArtifactPanel` - * to power the diff view. - */ -import { diffLines } from 'diff'; -import type { ContentBlock, RawMessage } from '@/stores/chat'; - export type FileContentType = | 'snapshot' | 'code' @@ -18,93 +6,6 @@ export type FileContentType = | 'audio' | 'other'; -/** A single (old_string -> new_string) replacement extracted from an edit tool. */ -export interface FileEditOp { - old: string; - new: string; -} - -export type GeneratedFileBaseline = - | { status: 'ok'; content: string } - | { status: 'missing' } - | { status: 'unavailable'; reason: string }; - -export interface FileLineStats { - added: number; - removed: number; -} - -export interface GeneratedFile { - filePath: string; - fileName: string; - ext: string; - mimeType: string; - contentType: FileContentType; - size?: number; - action: 'created' | 'modified'; - /** - * Full new content of the file when known (only set by `Write`-family - * tools that provide the whole document in their input). - */ - fullContent?: string; - /** - * Ordered list of edits applied to this file during the run (Edit / - * StrReplace / MultiEdit). The diff view renders these directly as a - * snippet diff (joined `old` vs joined `new`), matching WorkBuddy / - * Codex behaviour. - */ - edits?: FileEditOp[]; - /** - * File content captured immediately before a Write-family tool executed. - * `missing` means the file did not exist yet. `unavailable` means the - * renderer could not read the existing file precisely enough to build a - * trustworthy before/after diff (outside sandbox, binary, too large, ...). - */ - baseline?: GeneratedFileBaseline; - /** Index of the latest tool call that touched this file (for stable ordering). */ - lastSeenIndex: number; -} - -/** Visual separator between multiple edit hunks. */ -const SNIPPET_SEPARATOR = '\n\n'; - -/** - * True when the chat extraction captured enough tool payload to render a - * diff (Write `fullContent` and/or non-empty Edit ops). Entries without - * this should not appear in generated-file UIs. - */ -export function generatedFileHasDiffPayload(file: Pick): boolean { - if (file.fullContent != null) return true; - if (file.edits?.length) { - return file.edits.some((op) => (op.old ?? '') !== '' || (op.new ?? '') !== ''); - } - return false; -} - -const WRITE_TOOLS = new Set([ - 'Write', - 'write_file', - 'create_file', - 'WriteFile', - 'createFile', - 'write', -]); - -const EDIT_TOOLS = new Set([ - 'Edit', - 'edit', - 'edit_file', - 'EditFile', - 'StrReplace', - 'str_replace', - 'str_replace_editor', - 'MultiEdit', - 'multi_edit', - 'multiEdit', -]); - -const FILE_PATH_KEYS = ['file_path', 'filepath', 'path', 'fileName', 'file_name', 'target_path']; - /** Best-effort detector that mirrors the buckets WorkBuddy uses internally. */ const SNAPSHOT_EXTS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico', @@ -233,15 +134,6 @@ export function isPptxPreviewExt(ext: string | null | undefined): boolean { return PPTX_PREVIEW_EXTS.has(ext.toLowerCase()); } -export function supportsInlineDiff(file: Pick): boolean { - if (file.contentType === 'document') { - if (supportsRichDocumentPreview(file.ext)) return false; - return supportsInlineDocumentPreview(file.ext); - } - if (file.contentType === 'snapshot' || file.contentType === 'video' || file.contentType === 'audio') return false; - return true; -} - export function basenameOf(path: string): string { if (!path) return ''; const norm = path.replace(/\\/g, '/'); @@ -255,248 +147,3 @@ export function extnameOf(path: string): string { if (dot <= 0) return ''; return name.slice(dot); } - -function asRecord(input: unknown): Record | null { - if (!input || typeof input !== 'object') return null; - return input as Record; -} - -function pickFilePath(input: unknown): string | null { - const rec = asRecord(input); - if (!rec) return null; - for (const key of FILE_PATH_KEYS) { - const value = rec[key]; - if (typeof value === 'string' && value.trim()) { - return value.trim(); - } - } - return null; -} - -function pickWriteContent(input: unknown): string | undefined { - const rec = asRecord(input); - if (!rec) return undefined; - for (const key of [ - 'content', - 'contents', - 'text', - 'body', - 'data', - 'new_content', - 'new_string', - 'newString', - 'string', - 'source', - ]) { - const value = rec[key]; - if (typeof value === 'string') return value; - } - return undefined; -} - -// Aliases mirror WorkBuddy's tool-arg normaliser so we accept whichever -// naming convention the agent emits (Codex, Claude, Cursor, Cline …). -const OLD_KEYS = [ - 'old_string', 'oldString', 'old_str', 'oldStr', - 'old_text', 'oldText', - 'old', 'oldContent', 'before', 'find', 'search', -]; -const NEW_KEYS = [ - 'new_string', 'newString', 'new_str', 'newStr', - 'new_text', 'newText', - 'new', 'newContent', 'after', 'replace', 'replacement', -]; - -function pickStringByKeys(rec: Record, keys: string[]): string | undefined { - for (const k of keys) { - const v = rec[k]; - if (typeof v === 'string') return v; - } - return undefined; -} - -function pickEditOps(input: unknown): FileEditOp[] { - const rec = asRecord(input); - if (!rec) return []; - const ops: FileEditOp[] = []; - const singleOld = pickStringByKeys(rec, OLD_KEYS); - const singleNew = pickStringByKeys(rec, NEW_KEYS); - if (singleOld !== undefined || singleNew !== undefined) { - ops.push({ old: singleOld ?? '', new: singleNew ?? '' }); - } - const edits = rec.edits; - if (Array.isArray(edits)) { - for (const edit of edits as Array>) { - const o = pickStringByKeys(edit, OLD_KEYS) ?? ''; - const n = pickStringByKeys(edit, NEW_KEYS) ?? ''; - if (o !== '' || n !== '') ops.push({ old: o, new: n }); - } - } - return ops; -} - -function determineWriteAction( - existing: GeneratedFile | undefined, - baseline: GeneratedFileBaseline | undefined, -): 'created' | 'modified' { - if (existing?.action === 'created') return 'created'; - if (!baseline) return existing ? 'modified' : 'created'; - return baseline.status === 'missing' ? 'created' : 'modified'; -} - -function buildGeneratedFile( - filePath: string, - action: 'created' | 'modified', - parts: { fullContent?: string; edits?: FileEditOp[]; baseline?: GeneratedFileBaseline } | undefined, - index: number, -): GeneratedFile { - const fileName = basenameOf(filePath); - const ext = extnameOf(filePath); - return { - filePath, - fileName, - ext, - mimeType: getMimeTypeForExt(ext), - contentType: classifyFileExt(ext), - action, - fullContent: parts?.fullContent, - edits: parts?.edits, - baseline: parts?.baseline, - lastSeenIndex: index, - }; -} - -function normaliseEol(text: string): string { - return text.replace(/\r\n/g, '\n'); -} - -function joinEditText(edits: FileEditOp[], side: 'old' | 'new'): string { - return edits.map((op) => normaliseEol(op[side] ?? '')).join(SNIPPET_SEPARATOR); -} - -function countLogicalLines(text: string): number { - const normalized = normaliseEol(text); - if (!normalized) return 0; - const parts = normalized.split('\n'); - return normalized.endsWith('\n') ? Math.max(1, parts.length - 1) : parts.length; -} - -function diffLineStats(oldText: string, newText: string): FileLineStats { - const pieces = diffLines(normaliseEol(oldText), normaliseEol(newText)); - let added = 0; - let removed = 0; - for (const piece of pieces) { - const count = typeof piece.count === 'number' ? piece.count : countLogicalLines(piece.value); - if (piece.added) added += count; - if (piece.removed) removed += count; - } - return { added, removed }; -} - -export function computeLineStats(file: GeneratedFile): FileLineStats | null { - if (!supportsInlineDiff(file)) return null; - - if (file.edits?.length) { - return diffLineStats(joinEditText(file.edits, 'old'), joinEditText(file.edits, 'new')); - } - - if (file.fullContent == null) return null; - - if (file.baseline?.status === 'ok') { - return diffLineStats(file.baseline.content, file.fullContent); - } - - if (file.baseline?.status === 'missing') { - return { added: countLogicalLines(file.fullContent), removed: 0 }; - } - - if (file.baseline?.status === 'unavailable') { - return null; - } - - if (file.action === 'created') { - return { added: countLogicalLines(file.fullContent), removed: 0 }; - } - - return null; -} - -/** - * Walk the messages in `[triggerIndex, segmentEnd]` (inclusive) and - * collect the unique files written or edited by tool calls in that - * window. Deduplicates by `filePath`; if the file is touched by both - * a `Write` and a later `Edit`, the action is upgraded to `'modified'` - * but the diff content is kept from the last edit. - */ -export function extractGeneratedFiles( - messages: RawMessage[], - triggerIndex: number, - segmentEnd: number, - baselineGetter?: (filePath: string) => GeneratedFileBaseline | undefined, -): GeneratedFile[] { - const map = new Map(); - const start = Math.max(0, Math.min(triggerIndex + 1, messages.length)); - const end = Math.max(start - 1, Math.min(segmentEnd, messages.length - 1)); - - for (let i = start; i <= end; i += 1) { - const message = messages[i]; - if (!message || message.role !== 'assistant') continue; - const content = message.content; - if (!Array.isArray(content)) continue; - - for (const block of content as ContentBlock[]) { - if (block.type !== 'tool_use' && block.type !== 'toolCall') continue; - const name = typeof block.name === 'string' ? block.name : ''; - if (!name) continue; - const input = block.input ?? block.arguments; - const filePath = pickFilePath(input); - if (!filePath) continue; - - const isWrite = WRITE_TOOLS.has(name); - const isEdit = EDIT_TOOLS.has(name); - const looksLikeFile = isWrite || isEdit || /\.[a-z0-9]{1,8}$/i.test(basenameOf(filePath)); - if (!isWrite && !isEdit && !looksLikeFile) continue; - - const existing = map.get(filePath); - - if (isWrite) { - const newContent = pickWriteContent(input); - const baseline = baselineGetter?.(filePath); - const next = buildGeneratedFile( - filePath, - determineWriteAction(existing, baseline), - { fullContent: newContent, edits: undefined, baseline }, - i, - ); - map.set(filePath, next); - continue; - } - - if (isEdit) { - const newOps = pickEditOps(input); - const next = buildGeneratedFile( - filePath, - 'modified', - { - fullContent: existing?.fullContent, - edits: [...(existing?.edits ?? []), ...newOps], - baseline: existing?.baseline, - }, - i, - ); - map.set(filePath, next); - continue; - } - - // Unknown tool but its input mentions a file path with extension — - // surface as best-effort "modified" without diff content. - if (!existing) { - map.set(filePath, buildGeneratedFile(filePath, 'modified', undefined, i)); - } else { - existing.lastSeenIndex = i; - } - } - } - - return Array.from(map.values()).sort((a, b) => a.lastSeenIndex - b.lastSeenIndex); -} diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index 92d8c461..851ba0fe 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -7,7 +7,6 @@ import type { ChannelAccountsPayload, ChannelSaveConfigPayload, ChannelTargetsPayload, - ChatSendWithMediaPayload, ClawHubSearchPayload, CronSessionHistoryPayload, DialogMessagePayload, @@ -65,7 +64,6 @@ export type { ChannelSaveConfigResult, ChannelTargetOption, ChannelTargetsResult, - ChatSendWithMediaResult, ClawHubInstalledSkill, ClawHubListResult, ClawHubSearchResult, @@ -365,7 +363,6 @@ export const hostApi = { ), }, chat: { - sendWithMedia: (input: ChatSendWithMediaPayload) => invokeHost('chat', 'sendWithMedia', input), loadAcpSession: (input: AcpChatLoadPayload) => invokeHost('chat', 'loadAcpSession', input), sendAcpPrompt: (input: AcpChatPromptPayload) => invokeHost('chat', 'sendAcpPrompt', input), cancelAcpSession: (input: AcpChatCancelPayload) => invokeHost('chat', 'cancelAcpSession', input), diff --git a/src/main.tsx b/src/main.tsx index 58d9c496..940385bf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,7 @@ import App from './App'; import './i18n'; import './styles/globals.css'; import 'katex/dist/katex.min.css'; +import 'streamdown/styles.css'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/pages/Chat/AcpAssistantTurn.tsx b/src/pages/Chat/AcpAssistantTurn.tsx index cf60db36..7bd61d6a 100644 --- a/src/pages/Chat/AcpAssistantTurn.tsx +++ b/src/pages/Chat/AcpAssistantTurn.tsx @@ -118,12 +118,14 @@ function AcpTurnDuration({ timing }: { timing: AcpTurnTiming }) { export function AcpAssistantTurn({ group, + streamingSegmentIds, fileSummaries = [], workspaceRoot, timing, onPermissionSelect, }: { group: AcpAssistantTurnDisplayGroup; + streamingSegmentIds: ReadonlySet; fileSummaries?: AcpTurnFileSummary[]; workspaceRoot?: string; timing?: AcpTurnTiming; @@ -156,9 +158,18 @@ export function AcpAssistantTurn({ if (item.kind === 'message-segment') { if (item.role === 'user') return ; return ( -
+
{item.parts.map((part, index) => ( - + ))}
); diff --git a/src/pages/Chat/AcpMessageSegment.tsx b/src/pages/Chat/AcpMessageSegment.tsx index 8fe50d9d..15f976b4 100644 --- a/src/pages/Chat/AcpMessageSegment.tsx +++ b/src/pages/Chat/AcpMessageSegment.tsx @@ -1,11 +1,15 @@ -import { useCallback, useMemo, useState } from 'react'; -import ReactMarkdown from 'react-markdown'; -import rehypeKatex from 'rehype-katex'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, Check, Copy, Sparkles } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Streamdown, type Components } from 'streamdown'; import { BrowserLink } from '@/components/common/BrowserLink'; +import { + streamdownAnimation, + streamdownControls, + streamdownLinkSafety, + streamdownPlugins, + streamdownRehypePlugins, +} from '@/components/markdown/streamdown-config'; import type { MessageSegmentItem, RenderPart } from '@/lib/acp/timeline-types'; import { cn } from '@/lib/utils'; import { AcpImagePart, isSafeAcpImageSource } from './AcpImagePart'; @@ -13,6 +17,46 @@ import { AcpAttachmentPart } from './AcpAttachmentPart'; type RenderTone = 'assistant' | 'user' | 'process'; +const chatRemend = { linkMode: 'text-only' } as const; + +function AcpMarkdownImage({ src, alt }: { src?: string; alt?: string }) { + const { t } = useTranslation('chat'); + const imageSource = typeof src === 'string' ? src : ''; + if (!imageSource || !isSafeAcpImageSource(imageSource)) return null; + + return ( + {typeof + ); +} + +const chatMarkdownComponents: Components = { + strong: ({ children }) => ( + + {children} + + ), + a: ({ href, children }) => href ? ( + + {children} + + ) : <>{children}, + img: ({ src, alt }) => ( + + ), + inlineCode: ({ children }) => ( + + {children} + + ), +}; + function normalizeLatexDelimiters(input: string): string { if (!input || (input.indexOf('\\(') === -1 && input.indexOf('\\[') === -1)) return input; @@ -27,63 +71,45 @@ function normalizeLatexDelimiters(input: string): string { return parts.join(''); } -function AcpMarkdownPart({ text }: { text: string }) { - const { t } = useTranslation('chat'); +function AcpMarkdownPart({ text, isAnimating = false }: { text: string; isAnimating?: boolean }) { + const { t } = useTranslation('common'); + const containerRef = useRef(null); + const translations = useMemo(() => ({ + copyCode: t('markdown.copyCode'), + }), [t]); + + useEffect(() => { + if (isAnimating) return; + + for (const element of containerRef.current?.querySelectorAll('[data-sd-animate]') ?? []) { + element.removeAttribute('data-sd-animate'); + element.style.removeProperty('--sd-animation'); + element.style.removeProperty('--sd-duration'); + element.style.removeProperty('--sd-easing'); + element.style.removeProperty('--sd-delay'); + if (!element.style.length) element.removeAttribute('style'); + } + }, [isAnimating]); return ( -
- - {children} - - ); - } - return ( - - {children} - - ); - }, - pre({ children, ...props }) { - return ( -
-                {children}
-              
- ); - }, - a({ href, children }) { - return href ? ( - - {children} - - ) : <>{children}; - }, - img({ src, alt }) { - const imageSource = typeof src === 'string' ? src : ''; - if (!imageSource || !isSafeAcpImageSource(imageSource)) return null; - return ( - {typeof - ); - }, - }} +
+ {normalizeLatexDelimiters(text)} - +
); } @@ -146,7 +172,15 @@ export function AcpAssistantHoverBar({ text }: { text: string }) { ); } -export function AcpRenderPart({ part, tone = 'assistant' }: { part: RenderPart; tone?: RenderTone }) { +export const AcpRenderPart = memo(function AcpRenderPart({ + part, + tone = 'assistant', + isAnimating = false, +}: { + part: RenderPart; + tone?: RenderTone; + isAnimating?: boolean; +}) { if (part.kind === 'markdown') { if (tone === 'user') { return ( @@ -155,15 +189,15 @@ export function AcpRenderPart({ part, tone = 'assistant' }: { part: RenderPart;
); } - return ; + return ; } if (part.kind === 'image') return ; if (part.kind === 'attachment') return ; return ; -} +}); -export function AcpMessageSegment({ item }: { item: MessageSegmentItem }) { +export const AcpMessageSegment = memo(function AcpMessageSegment({ item }: { item: MessageSegmentItem }) { const isUser = item.role === 'user'; const clipboardText = useMemo(() => clipboardTextForParts(item.parts), [item.parts]); const orderedParts = useMemo(() => isUser @@ -193,4 +227,4 @@ export function AcpMessageSegment({ item }: { item: MessageSegmentItem }) {
); -} +}); diff --git a/src/pages/Chat/AcpTimeline.tsx b/src/pages/Chat/AcpTimeline.tsx index d4909349..02c5c90e 100644 --- a/src/pages/Chat/AcpTimeline.tsx +++ b/src/pages/Chat/AcpTimeline.tsx @@ -8,17 +8,9 @@ import type { AcpFileActivityProjection } from '@/lib/acp/openclaw-file-activiti import { AcpAttachmentPart } from './AcpAttachmentPart'; import type { AcpTurnTiming } from '@/lib/acp/turn-timings'; -export function AcpTimeline({ - snapshot, - error, - errorKind = 'load', - onDismissError, - onPermissionSelect, - fileActivity, - workspaceRoot, - turnTimingsByUserMessageId = {}, -}: { +type AcpTimelineProps = { snapshot: AcpTimelineSnapshot; + isStreaming?: boolean; error?: string | null; errorKind?: 'load' | 'prompt'; onDismissError?: () => void; @@ -26,8 +18,39 @@ export function AcpTimeline({ fileActivity?: AcpFileActivityProjection; workspaceRoot?: string; turnTimingsByUserMessageId?: Record; -}) { +}; + +export function streamingMessageSegmentIds( + snapshot: AcpTimelineSnapshot, + isStreaming: boolean, +): Set { + if (!isStreaming) return new Set(); + + const terminalItemId = snapshot.itemOrder.at(-1); + const terminalItem = terminalItemId ? snapshot.itemsById[terminalItemId] : undefined; + if ( + terminalItem?.kind !== 'message-segment' + || terminalItem.role !== 'assistant' + || snapshot.openMessageSegments[terminalItem.messageId] !== terminalItem.id + || terminalItem.parts.at(-1)?.kind !== 'markdown' + ) return new Set(); + + return new Set([terminalItem.id]); +} + +export function AcpTimeline({ + snapshot, + isStreaming = false, + error, + errorKind = 'load', + onDismissError, + onPermissionSelect, + fileActivity, + workspaceRoot, + turnTimingsByUserMessageId = {}, +}: AcpTimelineProps) { const groups = groupAcpTimelineItems(snapshot); + const streamingSegmentIds = streamingMessageSegmentIds(snapshot, isStreaming); return (
@@ -62,6 +85,7 @@ export function AcpTimeline({
; - /** - * Optional callback invoked when a non-image file card is clicked. - * When provided, the file opens in the in-app preview panel instead of - * the system default editor. - */ - onOpenFile?: (file: AttachedFileMeta) => void; -} - -interface ExtractedImage { url?: string; data?: string; mimeType: string; } - -const DIRECTORY_MIME_TYPE = 'application/x-directory'; - -function isChatPreviewDocument(file: AttachedFileMeta): boolean { - const name = file.fileName.toLowerCase(); - const mime = file.mimeType.toLowerCase(); - return ( - mime === 'application/pdf' - || mime === 'application/vnd.ms-excel' - || mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - || name.endsWith('.pdf') - || name.endsWith('.xls') - || name.endsWith('.xlsx') - ); -} - -function isDirectoryAttachment(file: AttachedFileMeta): boolean { - return file.mimeType === DIRECTORY_MIME_TYPE; -} - -function isSkillFileAttachment(file: AttachedFileMeta): boolean { - const path = file.filePath ?? ''; - return ( - /(?:^|[\\/])\.openclaw[\\/]skills[\\/][^\\/]+[\\/].+\.[A-Za-z0-9]+$/i.test(path) - || /(?:^|[\\/])skills[\\/][^\\/]+[\\/]SKILL\.md$/i.test(path) - ); -} - -function isHtmlOrMarkdownPreview(file: AttachedFileMeta): boolean { - const name = file.fileName.toLowerCase(); - const mime = file.mimeType.toLowerCase(); - return ( - mime === 'text/html' - || mime === 'text/markdown' - || name.endsWith('.html') - || name.endsWith('.htm') - || name.endsWith('.md') - || name.endsWith('.markdown') - ); -} - -/** User-facing artifacts that must stay visible when process output is folded into the graph. */ -function isUserFacingAttachmentWhenFolded(file: AttachedFileMeta): boolean { - if (file.mimeType.startsWith('image/')) return true; - if (isDirectoryAttachment(file)) return true; - if (isSkillFileAttachment(file)) return true; - if (isChatPreviewDocument(file)) return true; - // Paths parsed from the assistant reply (e.g. "/workspace/demo.html") are - // intentional user-facing links. Generic tool-result markdown attachments - // (e.g. CHECKLIST.md emitted mid-run) stay folded into the execution graph. - if (file.source === 'message-ref' && isHtmlOrMarkdownPreview(file)) return true; - return false; -} - -function validationKindForAttachment(file: AttachedFileMeta): 'file' | 'dir' | null { - if (!file.filePath) return null; - // User-selected uploads and already enriched attachments are trusted enough - // for immediate display. Regex-derived message refs start at size 0/null and - // are validated through main-process stat before becoming clickable cards. - if (file.source !== 'message-ref' && file.source !== 'tool-result') return null; - if (file.fileSize > 0 || file.preview) return null; - return isDirectoryAttachment(file) ? 'dir' : 'file'; -} - -function previewMimeFromPath(filePath: string): string | null { - const lower = filePath.toLowerCase(); - if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown'; - if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html'; - if (lower.endsWith('.pdf')) return 'application/pdf'; - if (lower.endsWith('.xls')) return 'application/vnd.ms-excel'; - if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; - return null; -} - -function fileNameFromPath(filePath: string): string { - return filePath.split(/[\\/]/).pop() || 'file'; -} - -function trimPathTerminators(filePath: string): string { - return filePath.replace(/[,。;;,.!?]+$/u, ''); -} - -function extractPreviewDocumentPaths(text: string): AttachedFileMeta[] { - if (!text) return []; - const refs: AttachedFileMeta[] = []; - const seen = new Set(); - const pushRef = (filePath: string, mimeType: string) => { - const normalizedPath = trimPathTerminators(filePath); - if (!normalizedPath || seen.has(normalizedPath)) return; - seen.add(normalizedPath); - refs.push({ - fileName: fileNameFromPath(normalizedPath), - mimeType, - fileSize: 0, - preview: null, - filePath: normalizedPath, - source: 'message-ref', - }); - }; - // Deliberately narrow this render-layer fallback to user-facing artifacts: - // HTML / Markdown / PDF / spreadsheet previews and OpenClaw skill directories. - // The store-level extractor still handles broad file categories; this keeps - // visible outputs clickable even before history enrichment runs. - const exts = 'html?|md|markdown|pdf|xlsx?|HTML?|MD|MARKDOWN|PDF|XLSX?'; - const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/)[^\\s\\n"'()\\[\\],<>]*?\\.(?:${exts}))`, 'g'); - const unixRegex = new RegExp('(?]*?\\.(?:' + exts + '))', 'g'); - const skillPathBoundary = '(?=$|\\s|[\\x5b\\x5d"\'`(),<>,。;;,.!?])'; - const skillPathPart = '[^\\\\/\\s\\n"\'`()\\x5b\\x5d,<>]+'; - const skillPathTail = '[^\\s\\n"\'`()\\x5b\\x5d,<>]*?'; - const skillDirRegex = new RegExp( - `(? `\n$$\n${body.trim()}\n$$\n`); - next = next.replace(/\\\(([\s\S]+?)\\\)/g, (_m, body: string) => `$${body}$`); - parts[i] = next; - } - return parts.join(''); -} - -/** Resolve an ExtractedImage to a displayable src string, or null if not possible. */ -function imageSrc(img: ExtractedImage): string | null { - if (img.url) return isUnresolvableImageUrl(img.url) ? null : img.url; - if (img.data) return `data:${img.mimeType};base64,${img.data}`; - return null; -} - -export const ChatMessage = memo(function ChatMessage({ - message, - textOverride, - suppressToolCards = false, - suppressProcessAttachments = false, - suppressAssistantText = false, - isStreaming = false, - streamingTools = [], - onOpenFile, -}: ChatMessageProps) { - const isUser = message.role === 'user'; - const role = typeof message.role === 'string' ? message.role.toLowerCase() : ''; - const isToolResult = role === 'toolresult' || role === 'tool_result'; - const text = textOverride ?? extractText(message); - // When text is folded into an ExecutionGraphCard, treat the message as - // having no text for rendering purposes. Keeping this behind a flag (vs - // blanking `text` outright) lets future hover affordances still read the - // original content without surfacing the bubble. - const hideAssistantText = suppressAssistantText && !isUser; - const hasText = !hideAssistantText && text.trim().length > 0; - const images = extractImages(message); - const resolvableContentImages = images.filter((img) => imageSrc(img) != null); - const tools = extractToolUse(message); - const visibleTools = suppressToolCards ? [] : tools; - const [validatedPaths, setValidatedPaths] = useState>({}); - const rawAttachedFiles = message._attachedFiles || []; - const textPreviewFiles = isUser ? [] : extractPreviewDocumentPaths(text); - const rawAttachedPaths = new Set(rawAttachedFiles.map((file) => file.filePath).filter(Boolean)); - const derivedAttachedFiles = [ - ...rawAttachedFiles, - ...textPreviewFiles.filter((file) => !file.filePath || !rawAttachedPaths.has(file.filePath)), - ]; - const validationTargets = derivedAttachedFiles - .map((file) => { - const kind = validationKindForAttachment(file); - return kind && file.filePath ? { filePath: file.filePath, kind } : null; - }) - .filter((target): target is { filePath: string; kind: 'file' | 'dir' } => !!target); - const validationKey = validationTargets - .map((target) => `${target.kind}:${target.filePath}`) - .sort() - .join('\n'); - useEffect(() => { - if (!validationKey) return; - const pendingTargets = validationTargets.filter((target) => validatedPaths[target.filePath] === undefined); - if (pendingTargets.length === 0) return; - - let cancelled = false; - void Promise.all( - pendingTargets.map(async (target) => { - try { - const stat = await statFile(target.filePath); - return { - filePath: target.filePath, - exists: !!stat.ok && (target.kind === 'dir' ? !!stat.isDir : !!stat.isFile), - }; - } catch { - return { filePath: target.filePath, exists: false }; - } - }), - ).then((results) => { - if (cancelled) return; - setValidatedPaths((current) => { - const next = { ...current }; - for (const result of results) next[result.filePath] = result.exists; - return next; - }); - }); - - return () => { - cancelled = true; - }; - }, [validationKey, validationTargets, validatedPaths]); - const existingDerivedAttachedFiles = derivedAttachedFiles.filter((file) => { - const kind = validationKindForAttachment(file); - if (!kind || !file.filePath) return true; - return validatedPaths[file.filePath] === true; - }); - const filteredProcessAttachments = derivedAttachedFiles.filter((file) => { - if (file.source !== 'tool-result' && file.source !== 'message-ref') return true; - // Runtime-produced user-facing artifacts (images, HTML/Markdown/PDF/XLSX, - // skill directories, ...) must remain visible in the reply bubble even - // when generic process attachments are folded into the execution graph. - // The graph card itself does not render `_attachedFiles`, so dropping - // them here would leave the user with no way to open previews from chat. - return isUserFacingAttachmentWhenFolded(file); - }); - // When a message is attachment-only, keep those attachments visible even if - // process attachments are generally suppressed for this run segment — - // otherwise the reply disappears entirely. - const processVisibleAttachments = filteredProcessAttachments.filter((file) => { - const kind = validationKindForAttachment(file); - if (!kind || !file.filePath) return true; - return validatedPaths[file.filePath] === true; - }); - const attachedFiles = suppressProcessAttachments && (hasText || resolvableContentImages.length > 0 || visibleTools.length > 0) - ? processVisibleAttachments - : existingDerivedAttachedFiles; - const imageCopyTarget = resolvePrimaryImageCopyTarget(resolvableContentImages, attachedFiles); - const showAssistantHoverBar = !isUser && (hasText || imageCopyTarget != null); - const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null); - - // Never render tool result messages in chat UI - if (isToolResult) return null; - - const hasStreamingToolStatus = isStreaming && streamingTools.length > 0; - if (!hasText && resolvableContentImages.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null; - - return ( -
- {/* Avatar — vertical center aligned with the first line of the reply. - The outer slot is sized to one prose-sm line (h-6 = 24px) so its - midpoint coincides with the first text line's midpoint; the 32px - avatar inside is centered within that slot and intentionally - overflows ±4px above/below the line, which mirrors how chat avatars - sit alongside a single line of text. */} - {!isUser && ( -
-
- -
-
- )} - - {/* Content */} -
- {isStreaming && !isUser && streamingTools.length > 0 && ( - - )} - - {/* Images — rendered ABOVE text bubble for user messages */} - {/* Images from content blocks (Gateway session data / channel push photos) */} - {isUser && resolvableContentImages.length > 0 && ( -
- {resolvableContentImages.map((img, i) => { - const src = imageSrc(img); - if (!src) return null; - return ( - setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })} - /> - ); - })} -
- )} - - {/* File attachments — images above text for user, file cards below */} - {isUser && attachedFiles.length > 0 && ( -
- {attachedFiles.map((file, i) => { - const isImage = file.mimeType.startsWith('image/'); - // Skip image attachments if we already have images from content blocks - if (isImage && resolvableContentImages.length > 0) return null; - if (isImage) { - return file.preview ? ( - setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })} - /> - ) : ( - - ); - } - // Non-image files → file card - return ; - })} -
- )} - - {/* Main text */} - {hasText && ( - isUser ? ( - - ) : ( - - ) - )} - - {/* Images from content blocks — assistant messages (below text) */} - {!isUser && resolvableContentImages.length > 0 && ( -
- {resolvableContentImages.map((img, i) => { - const src = imageSrc(img); - if (!src) return null; - return ( - setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })} - /> - ); - })} -
- )} - - {/* File attachments — assistant messages (below text) */} - {!isUser && attachedFiles.length > 0 && ( -
- {attachedFiles.map((file, i) => { - const isImage = file.mimeType.startsWith('image/'); - if (isImage && resolvableContentImages.length > 0) return null; - if (isImage && file.preview) { - return ( - setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })} - /> - ); - } - if (isImage && !file.preview) { - return ; - } - return ; - })} -
- )} - - {/* Hover row for user messages — timestamp only */} - {isUser && message.timestamp && ( - - {formatTimestamp(message.timestamp)} - - )} - - {/* Hover row for assistant messages */} - {showAssistantHoverBar && ( - - )} -
- - {/* Image lightbox portal */} - {lightboxImg && ( - setLightboxImg(null)} - /> - )} -
- ); -}); - -function formatDuration(durationMs?: number): string | null { - if (!durationMs || !Number.isFinite(durationMs)) return null; - if (durationMs < 1000) return `${Math.round(durationMs)}ms`; - return `${(durationMs / 1000).toFixed(1)}s`; -} - -function ToolStatusBar({ - tools, -}: { - tools: Array<{ - id?: string; - toolCallId?: string; - name: string; - status: 'running' | 'completed' | 'error'; - durationMs?: number; - summary?: string; - }>; -}) { - return ( -
- {tools.map((tool) => { - const duration = formatDuration(tool.durationMs); - const isRunning = tool.status === 'running'; - const isError = tool.status === 'error'; - return ( -
- {isRunning && } - {!isRunning && !isError && } - {isError && } - - {tool.name} - {duration && {tool.summary ? `(${duration})` : duration}} - {tool.summary && ( - {tool.summary} - )} -
- ); - })} -
- ); -} - -function resolvePrimaryImageCopyTarget( - images: Array<{ mimeType: string; data?: string; url?: string }>, - attachedFiles: AttachedFileMeta[], -): ImageCopyTarget | null { - for (const file of attachedFiles) { - if (!file.mimeType.startsWith('image/')) continue; - if (file.filePath || file.preview?.startsWith('data:')) { - return { - filePath: file.filePath, - preview: file.preview, - mimeType: file.mimeType, - }; - } - } - - for (const image of images) { - if (image.data) { - return { base64: image.data, mimeType: image.mimeType }; - } - if (image.url?.startsWith('data:')) { - return { preview: image.url, mimeType: image.mimeType }; - } - } - - return null; -} - -// ── Assistant hover bar (timestamp + copy, shown on group hover) ─ - -function AssistantHoverBar({ - text, - timestamp, - imageCopyTarget, -}: { - text: string; - timestamp?: number; - imageCopyTarget?: ImageCopyTarget | null; -}) { - const [copied, setCopied] = useState(false); - - const copyContent = useCallback(async () => { - if (imageCopyTarget) { - const copiedImage = await copyImageToClipboard(imageCopyTarget); - if (copiedImage) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - return; - } - } - if (!text.trim()) return; - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [text, imageCopyTarget]); - - return ( -
- - {timestamp ? formatTimestamp(timestamp) : ''} - - -
- ); -} - -// ── User Message Bubble ───────────────────────────────────────── - -function UserMessageBubble({ - text, -}: { - text: string; -}) { - return ( -
-

{text}

-
- ); -} - -// ── Assistant Markdown ────────────────────────────────────────── - -function AssistantMarkdown({ - text, - isStreaming, -}: { - text: string; - isStreaming: boolean; -}) { - return ( -
- - {children} - - ); - } - return ( - - {children} - - ); - }, - pre({ children, ...props }) { - return ( -
-                {children}
-              
- ); - }, - a({ href, children }) { - return href ? ( - - {children} - - ) : <>{children}; - }, - img({ src, alt }) { - if (!src || isUnresolvableImageUrl(String(src))) return null; - return ( - {typeof - ); - }, - }} - > - {normalizeLatexDelimiters(text)} -
- {isStreaming && ( - - )} -
- ); -} - -// ── File Card (for user-uploaded non-image files) ─────────────── - -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - -function FileIcon({ mimeType, className }: { mimeType: string; className?: string }) { - if (mimeType === DIRECTORY_MIME_TYPE) return ; - if (mimeType.startsWith('video/')) return ; - if (mimeType.startsWith('audio/')) return ; - if (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml') return ; - if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar') || mimeType.includes('7z')) return ; - if (mimeType === 'application/pdf') return ; - return ; -} - -function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: AttachedFileMeta) => void }) { - const handleOpen = useCallback(() => { - if (!file.filePath) return; - if (onOpen) { - onOpen(file); - } else { - void hostApi.shell.openPath(file.filePath); - } - }, [file, onOpen]); - - return ( -
- -
-

{file.fileName}

-

- {file.mimeType === DIRECTORY_MIME_TYPE ? '文件夹' : file.fileSize > 0 ? formatFileSize(file.fileSize) : 'File'} -

-
-
- ); -} - -function ImagePreviewPlaceholder({ file }: { file: AttachedFileMeta }) { - const { t } = useTranslation('chat'); - const unavailable = file.previewStatus === 'unavailable'; - const label = unavailable - ? t('imageGeneration.previewUnavailable') - : t('imageGeneration.previewLoading'); - - return ( -
- {unavailable ? ( - - ) : ( - - )} - {label} -
- ); -} - -// ── Image Thumbnail (user bubble — square crop with zoom hint) ── - -function ImageThumbnail({ - src, - fileName, - filePath, - base64, - mimeType, - onPreview, -}: { - src: string; - fileName: string; - filePath?: string; - base64?: string; - mimeType?: string; - onPreview: () => void; -}) { - void filePath; void base64; void mimeType; - return ( -
- {fileName} -
- -
-
- ); -} - -// ── Image Preview Card (assistant bubble — natural size with overlay actions) ── - -function ImagePreviewCard({ - src, - fileName, - filePath, - base64, - mimeType, - onPreview, -}: { - src: string; - fileName: string; - filePath?: string; - base64?: string; - mimeType?: string; - onPreview: () => void; -}) { - void filePath; void base64; void mimeType; - return ( -
- {fileName} -
- -
-
- ); -} - -// ── Image Lightbox ─────────────────────────────────────────────── - -function ImageLightbox({ - src, - fileName, - filePath, - base64, - mimeType, - onClose, -}: { - src: string; - fileName: string; - filePath?: string; - base64?: string; - mimeType?: string; - onClose: () => void; -}) { - void src; void base64; void mimeType; void fileName; - - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [onClose]); - - const handleShowInFolder = useCallback(() => { - if (filePath) { - void hostApi.shell.showItemInFolder(filePath); - } - }, [filePath]); - - return createPortal( -
- {/* Image + buttons stacked */} -
e.stopPropagation()} - > - {fileName} - - {/* Action buttons below image */} -
- {filePath && ( - - )} - -
-
-
, - document.body, - ); -} diff --git a/src/pages/Chat/ChatToolbar.tsx b/src/pages/Chat/ChatToolbar.tsx index c42043f2..90df134d 100644 --- a/src/pages/Chat/ChatToolbar.tsx +++ b/src/pages/Chat/ChatToolbar.tsx @@ -14,7 +14,7 @@ import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; import { WORKSPACE_BROWSER_ENABLED } from '@/components/file-preview/workspace-browser-config'; -type ChatToolbarProps = { +export type ChatToolbarProps = { questionDirectoryOpen?: boolean; questionDirectoryCount?: number; onToggleQuestionDirectory?: () => void; diff --git a/src/pages/Chat/ExecutionGraphCard.tsx b/src/pages/Chat/ExecutionGraphCard.tsx deleted file mode 100644 index 3d5c4c5e..00000000 --- a/src/pages/Chat/ExecutionGraphCard.tsx +++ /dev/null @@ -1,320 +0,0 @@ -// Legacy Gateway Chat visualization retained only for tests or non-ACP migration cleanup. -// ACP Chat renders ordered inline timeline blocks instead. -import { useState } from 'react'; -import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, GitBranch, Link, MessageSquare, Wrench, XCircle } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { BrowserLink } from '@/components/common/BrowserLink'; -import { cn } from '@/lib/utils'; -import type { TaskStep } from './task-visualization'; - -interface ExecutionGraphCardProps { - agentLabel: string; - steps: TaskStep[]; - active: boolean; - /** Hide the trailing "Thinking ..." indicator even when active. */ - suppressThinking?: boolean; - /** - * When provided, the card becomes fully controlled: the parent owns the - * expand state (e.g. to persist across remounts) and toggling goes through - * `onExpandedChange`. When omitted, the card manages its own local state. - */ - expanded?: boolean; - onExpandedChange?: (expanded: boolean) => void; -} - -const TOOL_ROW_EXTRA_INDENT_PX = 8; - -function AnimatedDots({ className }: { className?: string }) { - return ( - - ); -} - -function GraphStatusIcon({ status }: { status: TaskStep['status'] }) { - if (status === 'completed') return ; - if (status === 'error') return ; - return ; -} - -function StepDetailCard({ step }: { step: TaskStep }) { - const { t } = useTranslation('chat'); - const [expanded, setExpanded] = useState(false); - const hasDetail = !!step.detail; - // Narration steps (intermediate pure-text assistant messages folded from - // the chat stream) are rendered without a label/status pill: the message - // text IS the primary content. - const isNarration = step.kind === 'message'; - const isTool = step.kind === 'tool'; - const isThinking = step.kind === 'thinking'; - const displayToolLabel = isTool && step.label === 'image_generate' - ? t('executionGraph.imageGenerateLabel') - : step.label; - // System steps (subagent branch roots etc.) share the tool row layout: - // bold label + truncated single-line detail preview + click-to-expand, - // i.e. no rounded card / no separate detail line below the title. - const isSystem = step.kind === 'system'; - const isFlatRow = isTool || isSystem; - const showRunningDots = (isTool || isThinking) && step.status === 'running'; - const hideStatusText = (isTool || isSystem) && step.status === 'completed'; - const detailPreview = step.detail?.replace(/\s+/g, ' ').trim(); - const canExpand = hasDetail; - const displayLabel = isThinking ? t('executionGraph.thinkingLabel') : (isTool ? displayToolLabel : step.label); - - return ( -
- - {step.detail && expanded && canExpand && isFlatRow && (() => { - // Tool inputs are typically JSON; system payloads (e.g. subagent - // session keys) are usually plain strings. Pretty-print if the - // detail parses as JSON, otherwise fall back to the raw text so - // session keys render unchanged. - let formatted = step.detail; - try { - formatted = JSON.stringify(JSON.parse(step.detail), null, 2); - } catch { /* not valid JSON */ } - return ( -
-
-                  {formatted}
-                
-
- ); - })()} - {step.detail && expanded && canExpand && (isNarration || isThinking) && ( -
-
-                {step.detail}
-              
-
- )} -
- ); -} - -export function ExecutionGraphCard({ - agentLabel, - steps, - active, - suppressThinking = false, - expanded: controlledExpanded, - onExpandedChange, -}: ExecutionGraphCardProps) { - const { t } = useTranslation('chat'); - - // Active runs should stay expanded by default so the user can follow the - // execution live. Once the run completes, the default state returns to - // collapsed. Explicit user toggles remain controlled by the parent override. - const [uncontrolledExpanded, setUncontrolledExpanded] = useState(active); - const [prevActive, setPrevActive] = useState(active); - if (prevActive !== active) { - setPrevActive(active); - if (controlledExpanded == null && uncontrolledExpanded !== active) { - setUncontrolledExpanded(active); - } - } - - const isControlled = controlledExpanded != null; - const expanded = isControlled ? controlledExpanded : uncontrolledExpanded; - const setExpanded = (next: boolean) => { - if (!isControlled) setUncontrolledExpanded(next); - onExpandedChange?.(next); - }; - - const toolCount = steps.filter((step) => step.kind === 'tool').length; - const processCount = steps.length - toolCount; - const shouldShowTrailingThinking = active && !suppressThinking; - - if (!expanded) { - return ( - - ); - } - - return ( -
- - -
-
-
-
- -
-
-
- - {t('executionGraph.agentRun', { agent: agentLabel })} - -
-
- - {steps.map((step) => { - const alignedIndentOffset = ( - step.kind === 'tool' - || step.kind === 'message' - || step.kind === 'thinking' - ) ? TOOL_ROW_EXTRA_INDENT_PX : 0; - const rowMarginLeft = (Math.max(step.depth - 1, 0) * 24) + alignedIndentOffset; - return ( -
-
-
-
-
-
-
- {step.depth > 1 && ( -
- )} -
- {step.kind === 'thinking' - ? - : step.kind === 'tool' - ? - : step.kind === 'message' - ? - : } -
-
-
- -
-
- )})} - {shouldShowTrailingThinking && ( -
-
-
-
-
-
-
- {t('executionGraph.thinkingLabel')} - -
-
-
- )} -
-
- ); -} diff --git a/src/pages/Chat/image-generation-status.ts b/src/pages/Chat/image-generation-status.ts deleted file mode 100644 index b137d6bf..00000000 --- a/src/pages/Chat/image-generation-status.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { RawMessage, ToolStatus } from '@/stores/chat'; -import { - extractImages, - extractToolUse, - isGeneratingStatusNarration, - isInternalAssistantReplyText, -} from './message-utils'; - -const IMAGE_GENERATE_TOOL = 'image_generate'; -const ASYNC_IMAGE_TASK_START_RE = /Background task started for image generation \(([0-9a-f-]{36})\)/i; -const INTER_SESSION_IMAGE_TASK_RE = /sourceSession=image_generate:([0-9a-f-]{36})/i; -/** Match OpenClaw agents.defaults.imageGenerationModel.timeoutMs default. */ -export const IMAGE_GENERATION_TIMEOUT_MS = 180_000; -const IMAGE_GENERATION_TIMEOUT_BUFFER_MS = 15_000; - -function toMs(timestamp: number | undefined): number | null { - if (timestamp == null || !Number.isFinite(timestamp)) return null; - return timestamp < 1e12 ? timestamp * 1000 : timestamp; -} - -function getMessagePlainText(message: RawMessage): string { - const { content } = message; - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - const parts: string[] = []; - for (const block of content as Array<{ type?: string; text?: string }>) { - if (block.type === 'text' && typeof block.text === 'string') { - parts.push(block.text); - } - } - return parts.join('\n'); -} - -function looksLikeImagePath(value: unknown): boolean { - return typeof value === 'string' && /\.(?:png|jpe?g|gif|webp|bmp|avif|svg)(?:$|[?#])/i.test(value.trim()); -} - -function messageToolResultDeliveredImage(message: RawMessage): boolean { - const role = String(message.role ?? '').toLowerCase(); - if (role !== 'toolresult' && role !== 'tool_result') return false; - if (message.toolName !== 'message') return false; - - const details = (message as unknown as { details?: unknown }).details; - if (!details || typeof details !== 'object') return false; - const record = details as Record; - const sourceReply = record.sourceReply && typeof record.sourceReply === 'object' - ? record.sourceReply as Record - : null; - const mediaValues = [ - record.mediaUrl, - ...(Array.isArray(record.mediaUrls) ? record.mediaUrls : []), - sourceReply?.mediaUrl, - ...(Array.isArray(sourceReply?.mediaUrls) ? sourceReply.mediaUrls : []), - ]; - return mediaValues.some(looksLikeImagePath); -} - -function messageHasDeliveredImage(message: RawMessage): boolean { - if ((message._attachedFiles ?? []).some((file) => file.mimeType.startsWith('image/'))) { - return true; - } - if (extractImages(message).length > 0) return true; - return messageToolResultDeliveredImage(message); -} - -function findLastImageGenerateToolCallIndex(segmentMessages: RawMessage[]): number { - for (let index = segmentMessages.length - 1; index >= 0; index -= 1) { - const message = segmentMessages[index]; - if (message.role !== 'assistant') continue; - if (extractToolUse(message).some((tool) => tool.name === IMAGE_GENERATE_TOOL)) { - return index; - } - } - return -1; -} - -function collectAsyncImageTaskStarts( - segmentMessages: RawMessage[], -): Array<{ taskId: string; startedAtMs: number | null }> { - const starts: Array<{ taskId: string; startedAtMs: number | null }> = []; - for (const message of segmentMessages) { - const role = String(message.role ?? '').toLowerCase(); - if (role !== 'toolresult' && role !== 'tool_result') continue; - if (message.toolName !== IMAGE_GENERATE_TOOL) continue; - const match = getMessagePlainText(message).match(ASYNC_IMAGE_TASK_START_RE); - if (!match?.[1]) continue; - starts.push({ - taskId: match[1], - startedAtMs: toMs(message.timestamp), - }); - } - return starts; -} - -function collectCompletedAsyncImageTaskIds(segmentMessages: RawMessage[]): Set { - const completed = new Set(); - for (const message of segmentMessages) { - const role = String(message.role ?? '').toLowerCase(); - if (role !== 'user') continue; - const text = getMessagePlainText(message).trim(); - if (!/^\[Inter-session message\]/i.test(text)) continue; - const match = text.match(INTER_SESSION_IMAGE_TASK_RE); - if (match?.[1]) completed.add(match[1]); - } - return completed; -} - -function hasTimedOut(startedAtMs: number | null, now: number): boolean { - if (startedAtMs == null) return false; - return now - startedAtMs > IMAGE_GENERATION_TIMEOUT_MS + IMAGE_GENERATION_TIMEOUT_BUFFER_MS; -} - -function hasDeliveredImageAfter(segmentMessages: RawMessage[], fromIndex: number): boolean { - for (let index = fromIndex + 1; index < segmentMessages.length; index += 1) { - if (messageHasDeliveredImage(segmentMessages[index])) return true; - } - return false; -} - -function hasTerminalReplyAfterToolCall(segmentMessages: RawMessage[], fromIndex: number): boolean { - for (let index = fromIndex + 1; index < segmentMessages.length; index += 1) { - const message = segmentMessages[index]; - if (message.role !== 'assistant') continue; - if (extractToolUse(message).length > 0) continue; - - const text = getMessagePlainText(message).trim(); - if (!text || isInternalAssistantReplyText(text)) continue; - if (isGeneratingStatusNarration(text)) continue; - return true; - } - return false; -} - -function hasFreshRunningStreamingTool(streamingTools: ToolStatus[], now: number): boolean { - return streamingTools.some((tool) => { - if (tool.name !== IMAGE_GENERATE_TOOL || tool.status !== 'running') return false; - return now - tool.updatedAt <= IMAGE_GENERATION_TIMEOUT_MS + IMAGE_GENERATION_TIMEOUT_BUFFER_MS; - }); -} - -export function hasDeliveredImageGenerationResult(segmentMessages: RawMessage[]): boolean { - const toolCallIndex = findLastImageGenerateToolCallIndex(segmentMessages); - if (toolCallIndex >= 0 && hasDeliveredImageAfter(segmentMessages, toolCallIndex)) { - return true; - } - - const asyncStarts = collectAsyncImageTaskStarts(segmentMessages); - if (asyncStarts.length === 0) return false; - return segmentMessages.some((message) => message.role === 'assistant' && messageHasDeliveredImage(message)); -} - -/** True while an async `image_generate` task is in flight for this run segment. */ -export function isImageGenerationPending( - segmentMessages: RawMessage[], - streamingTools: ToolStatus[] = [], - now = Date.now(), -): boolean { - const toolCallIndex = findLastImageGenerateToolCallIndex(segmentMessages); - const asyncStarts = collectAsyncImageTaskStarts(segmentMessages); - const completedTaskIds = collectCompletedAsyncImageTaskIds(segmentMessages); - - if (toolCallIndex < 0 && asyncStarts.length === 0) { - return hasFreshRunningStreamingTool(streamingTools, now); - } - - const toolCallStartedAt = toolCallIndex >= 0 - ? toMs(segmentMessages[toolCallIndex]?.timestamp) - : null; - const latestAsyncStartedAt = asyncStarts.reduce((latest, start) => { - if (start.startedAtMs == null) return latest; - return latest == null ? start.startedAtMs : Math.max(latest, start.startedAtMs); - }, null); - const anchorStartedAt = [toolCallStartedAt, latestAsyncStartedAt] - .filter((value): value is number => value != null) - .reduce((latest, value) => (latest == null ? value : Math.max(latest, value)), null); - - if (hasTimedOut(anchorStartedAt, now)) { - return false; - } - - if (hasDeliveredImageGenerationResult(segmentMessages)) return false; - - const hasOpenAsyncTask = asyncStarts.some((start) => !completedTaskIds.has(start.taskId)); - if (asyncStarts.length > 0) { - if (!hasOpenAsyncTask) return false; - } else if (toolCallIndex >= 0 && hasTerminalReplyAfterToolCall(segmentMessages, toolCallIndex)) { - return false; - } - - return hasFreshRunningStreamingTool(streamingTools, now) - || toolCallIndex >= 0 - || hasOpenAsyncTask; -} diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index bf316c5a..22b6753e 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -1,9 +1,8 @@ /** * Chat Page - * ACP-native runtime rendering. The legacy Gateway execution graph remains in - * the codebase but is no longer part of the primary Chat render path. + * ACP-native runtime rendering through the ordered inline timeline. */ -import { Suspense, lazy, useEffect, useMemo, useRef, useState } from 'react'; +import { Suspense, lazy, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, ArrowDownToLine, FolderOpen } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; @@ -26,6 +25,7 @@ import { import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant'; import { getAcpUserMessageAnchorId } from '@/lib/acp/timeline-anchors'; import type { MessageSegmentItem, RenderPart } from '@/lib/acp/timeline-types'; +import { createEmptyAcpTimeline } from '@/lib/acp/reducer'; import { projectOpenClawFileActivities, type AcpFileActivityProjection } from '@/lib/acp/openclaw-file-activities'; import { hostApi } from '@/lib/host-api'; import { getSessionDisplayTitle } from '@shared/chat/session-title'; @@ -256,6 +256,16 @@ export function Chat() { ); const acpTimeline = useAcpChatSessionStore((s) => s.timeline); + const renderedAcpTimeline = useDeferredValue(acpTimeline); + const emptyCurrentTimeline = useMemo( + () => createEmptyAcpTimeline(currentSessionKey ?? '', 0), + [currentSessionKey], + ); + const visibleAcpTimeline = renderedAcpTimeline.sessionId === currentSessionKey + ? renderedAcpTimeline + : acpTimeline.sessionId === currentSessionKey + ? acpTimeline + : emptyCurrentTimeline; const acpTurnTimings = useAcpChatSessionStore((s) => s.turnTimingsByUserMessageId); const acpLoading = useAcpChatSessionStore((s) => s.loading); const acpSending = useAcpChatSessionStore((s) => s.sending); @@ -371,6 +381,7 @@ export function Chat() { if (currentSession?.createdLocally) return; const createIfMissing = !currentSession; acpLoadInFlightKeyRef.current = acpLoadKey; + if (createIfMissing) selectAcpSession(currentSessionKey, cwd); void loadAcpSession({ sessionKey: currentSessionKey, workspaceRoot: cwd, @@ -378,20 +389,20 @@ export function Chat() { ...(createIfMissing ? { createIfMissing: true } : {}), }).then((loaded) => { if (loaded && createIfMissing) { - acknowledgeAcpSessionCreated(currentSessionKey); + acknowledgeAcpSessionCreated(currentSessionKey, cwd); } }).finally(() => { if (acpLoadInFlightKeyRef.current === acpLoadKey) { acpLoadInFlightKeyRef.current = null; } }); - }, [acknowledgeAcpSessionCreated, acpActiveSessionKey, acpCwd, acpWorkspaceRoot, currentSessionKey, cwd, loadAcpSession, sessionDiscoveryAttempted, sessions, workspaceContextAvailable]); + }, [acknowledgeAcpSessionCreated, acpActiveSessionKey, acpCwd, acpWorkspaceRoot, currentSessionKey, cwd, loadAcpSession, selectAcpSession, sessionDiscoveryAttempted, sessions, workspaceContextAvailable]); const platform = window.electron?.platform; const isMac = platform === 'darwin'; const isWindows = platform === 'win32'; const composerBusy = acpSending || acpCancelling; - const showScrollToLatest = acpTimeline.itemOrder.length > 0 && !isAtBottom; + const showScrollToLatest = visibleAcpTimeline.itemOrder.length > 0 && !isAtBottom; const hasAttemptedAcpPromptForCurrentSession = lastPromptAttemptSessionKey === currentSessionKey; const visibleAcpError = !workspaceUnavailable && acpError && !(acpTimeline.itemOrder.length === 0 && !hasAttemptedAcpPromptForCurrentSession && isRecoverableInitialAcpLoadError(acpError)) @@ -416,24 +427,24 @@ export function Chat() { || resolvedWorkspaceContext?.key !== workspaceContextKey || resolvedWorkspaceContext.sessionKey !== currentSessionKey || acpActiveSessionKey !== currentSessionKey - || acpTimeline.sessionId !== currentSessionKey + || visibleAcpTimeline.sessionId !== currentSessionKey ) return EMPTY_FILE_ACTIVITY; return projectOpenClawFileActivities({ - timeline: acpTimeline, + timeline: visibleAcpTimeline, workspaceRoot: resolvedWorkspaceContext.workspaceRoot, executionCwd: resolvedWorkspaceContext.executionCwd, }); - }, [acpActiveSessionKey, acpTimeline, currentSessionKey, resolvedWorkspaceContext, workspaceContextKey]); + }, [acpActiveSessionKey, currentSessionKey, resolvedWorkspaceContext, visibleAcpTimeline, workspaceContextKey]); const questionDirectoryItems = useMemo(() => { - const userItems = acpTimeline.itemOrder - .map((itemId) => acpTimeline.itemsById[itemId]) + const userItems = visibleAcpTimeline.itemOrder + .map((itemId) => visibleAcpTimeline.itemsById[itemId]) .filter((item): item is MessageSegmentItem => item?.kind === 'message-segment' && item.role === 'user'); return userItems.map((item, index) => ({ itemId: item.id, anchorId: getAcpUserMessageAnchorId(item.id), title: buildQuestionDirectoryTitle(item, t('questionDirectory.fallback', { number: index + 1 })), })); - }, [acpTimeline, t]); + }, [t, visibleAcpTimeline]); const questionDirectoryVisible = questionDirectoryOpenSessionKey === currentSessionKey && questionDirectoryItems.length > 1; @@ -495,11 +506,12 @@ export function Chat() {
- ) : acpTimeline.itemOrder.length === 0 ? ( + ) : visibleAcpTimeline.itemOrder.length === 0 ? ( ) : ( session.key === sessionKey); setLastPromptAttemptSessionKey(sessionKey); const promptCwd = targetAgent?.workspace || cwd; const media = attachments @@ -551,7 +564,7 @@ export function Chat() { fileName: file.fileName, mimeType: file.mimeType, })); - if (targetAgent) { + if (targetAgent || !existingSession) { selectAcpSession(sessionKey, promptCwd); } void (async () => { @@ -562,7 +575,6 @@ export function Chat() { }).catch(() => ({ ok: false })); if (!promptWorkspace.ok) return; } - const existingSession = sessions.find((session) => session.key === sessionKey); const createIfMissing = !existingSession || !!existingSession.createdLocally; if ( createIfMissing diff --git a/src/pages/Chat/message-utils.ts b/src/pages/Chat/message-utils.ts deleted file mode 100644 index b7b4fdae..00000000 --- a/src/pages/Chat/message-utils.ts +++ /dev/null @@ -1,578 +0,0 @@ -/** - * Message content extraction helpers - * Ported from OpenClaw's message-extract.ts to handle the various - * message content formats returned by the Gateway. - */ -import type { RawMessage, ContentBlock } from '@/stores/chat'; - -/** - * Strip OpenClaw's inbound-image vision envelope from user message text. - * The runtime expands uploaded images into: - * [Image] - * User text: - * - * Description: - * - */ -function stripInboundMediaVisionEnvelope(text: string): string { - if (!/\[Image\]/i.test(text) && !/^User text:/im.test(text) && !/\nDescription:\s*\n/i.test(text)) { - return text; - } - - let result = text.replace(/^\s*\[Image\]\s*\n?/i, ''); - - const userTextBlock = result.match(/^User text:\s*\n([\s\S]*?)(?:\n\s*Description:\s*\n[\s\S]*)?\s*$/i); - if (userTextBlock) { - const userText = userTextBlock[1].trim(); - return /^Process the attached file\(s\)\.\s*$/i.test(userText) ? '' : userText; - } - - return result.replace(/\n\s*Description:\s*\n[\s\S]*$/i, '').trim(); -} - -/** - * Clean Gateway metadata from user message text for display. - * Strips: [media attached: ... | ...], [message_id: ...], - * and the timestamp prefix [Day Date Time Timezone]. - */ -function cleanUserText(text: string): string { - return stripInboundMediaVisionEnvelope( - text - // Remove [media attached: path (mime) | path] references - .replace(/\s*\[media attached:[^\]]*\]/g, '') - // Remove [message_id: uuid] - .replace(/\s*\[message_id:\s*[^\]]+\]/g, '') - // Remove Gateway-injected sender metadata block. Some transcripts use - // "Sender (untrusted metadata):" instead of the older "Sender (untrusted):". - .replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*[^\n]*(?:\n\s*)*/i, '') - .replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^Sender\s*:\s*[^\n]*(?:\n\s*)*/i, '') - // Remove Gateway-injected "Conversation info (untrusted metadata): ```json...```" block - .replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - // Fallback: remove "Conversation info (...): {...}" without code block wrapper - .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '') - // Remove Gateway timestamp prefix like [Fri 2026-02-13 22:39 GMT+8] - .replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '') - .trim(), - ); -} - -/** - * Strip `MEDIA:/path/to/file.ext` / `MEDIA:C:\path\file.ext` artifact markers from assistant text so - * the chat bubble doesn't duplicate the file already surfaced as a card. - * - * Mirrors the regex in `chat/helpers.ts::extractRawFilePaths` (tagged - * variant) so anything we promote to `_attachedFiles` is also removed - * from the visible bubble. Whitespace around the marker is normalised - * so the bubble doesn't end with a dangling blank line. - */ -function stripAssistantMediaTags(text: string): string { - if (!text) return text; - const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v'; - // Mirror the relaxed character class in `chat/helpers.ts::extractRawFilePaths` - // so paths with ASCII spaces (e.g. macOS' "截屏 2026-05-06 17.46.51.png") - // are also stripped from the visible bubble. Without this, the bubble - // would still leak the literal `MEDIA:/.../截屏 2026-05-06 17.46.51.png` - // to the user when the underlying path detection succeeds. - // Allow MEDIA: after punctuation/emojis (e.g. `必备~MEDIA:/path`) — only reject - // when immediately preceded by word/path characters so `someMEDIA:` stays literal. - const tagged = new RegExp(`(?]*?\\.(?:${exts})(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g'); - // Bare OpenClaw artifact paths emitted alongside `_attachedFiles` cards. - // Scope to `.openclaw/media/` so normal absolute paths in prose stay visible. - const bareOpenClawMedia = new RegExp(`(^|[\\s(\\[{>])(?:(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.openclaw[\\\\/]media[\\\\/][^\\n"'()\\[\\],<>]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g'); - return text - .replace(tagged, '') - .replace(bareOpenClawMedia, (_, lead: string) => lead) - .replace(/!\[[^\]]*\]\([^)]+\)/g, '') - // Collapse the empty lines / orphan whitespace the strip leaves behind. - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -function normalizeProgressiveText(text: string | undefined): string { - return typeof text === 'string' ? text.replace(/\r\n/g, '\n').trim() : ''; -} - -function compactProgressiveParts(parts: string[]): string[] { - const compacted: string[] = []; - - for (const part of parts) { - const current = normalizeProgressiveText(part); - if (!current) continue; - - const previous = compacted.at(-1); - if (!previous) { - compacted.push(part); - continue; - } - - const normalizedPrevious = normalizeProgressiveText(previous); - if (!normalizedPrevious) { - compacted[compacted.length - 1] = part; - continue; - } - - if (current === normalizedPrevious || normalizedPrevious.startsWith(current)) { - continue; - } - - if (current.startsWith(normalizedPrevious)) { - compacted[compacted.length - 1] = part; - continue; - } - - compacted.push(part); - } - - return compacted; -} - -function splitProgressiveParts(parts: string[]): string[] { - const segments: string[] = []; - let previous = ''; - - for (const part of parts) { - const current = normalizeProgressiveText(part); - if (!current) continue; - - if (!previous) { - segments.push(current); - previous = current; - continue; - } - - if (current === previous || previous.startsWith(current)) { - continue; - } - - if (current.startsWith(previous)) { - const incremental = current.slice(previous.length).trim(); - if (incremental) { - segments.push(incremental); - } - previous = current; - continue; - } - - segments.push(current); - previous = current; - } - - return segments; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function consumeLeadingSegment(text: string, segment: string): number { - const tokens = segment.trim().split(/\s+/).filter(Boolean); - if (tokens.length === 0) return 0; - const pattern = new RegExp(`^\\s*${tokens.map(escapeRegExp).join('\\s+')}\\s*`, 'u'); - const match = text.match(pattern); - return match ? match[0].length : 0; -} - -/** True for OpenClaw internal assistant tokens that must never appear in the chat UI. */ -export function isInternalAssistantReplyText(text: string): boolean { - return /^(HEARTBEAT_OK|NO_REPLY)\s*$/i.test(text.trim()); -} - -/** - * Remove standalone internal sentinel lines (`NO_REPLY` / `HEARTBEAT_OK`) that - * the model sometimes appends *after* a real reply. The whole-message form is - * caught by `isInternalAssistantReplyText`; this covers the mixed case so the - * sentinel never leaks into the rendered chat bubble. - */ -export function stripInternalSentinelLines(text: string): string { - if (!text) return text; - return text - .replace(/(^|\n)[ \t]*(?:HEARTBEAT_OK|NO_REPLY)[ \t]*(?=\n|$)/gi, '$1') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -/** Interim image-generation status the user should not see as a final answer. */ -export function isGeneratingStatusNarration(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - if (/^(?:图片(?:正在)?生成中|正在生成(?:图片|图像)|生成中)/i.test(trimmed)) return true; - if (/稍等(片刻|一下)?/.test(trimmed) && trimmed.length <= 80 && /生成|generat/i.test(trimmed)) return true; - return false; -} - -/** OpenClaw runtime continuation prompt injected between internal async events. */ -export function isOpenClawRuntimeEventPrompt(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - if (/^Continue the OpenClaw runtime event\.?\s*$/i.test(trimmed)) return true; - // Some transcripts store the prompt as the only line in a longer envelope. - return trimmed.split(/\n+/).some((line) => /^Continue the OpenClaw runtime event\.?\s*$/i.test(line.trim())); -} - -/** Model-side planning about message-tool vs MEDIA delivery — never user-facing. */ -function isInternalDeliveryPlanningParagraph(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - if (/message tool isn't suitable/i.test(trimmed)) return true; - if (/visible-reply contract/i.test(trimmed)) return true; - if (/final-reply MEDIA lines/i.test(trimmed)) return true; - if (/writing the normal final reply with MEDIA directives/i.test(trimmed)) return true; - if (/webchat isn't a valid channel for the message tool/i.test(trimmed)) return true; - if (/fall back to writing the normal final reply/i.test(trimmed)) return true; - return false; -} - -/** Remove internal delivery-planning paragraphs the model sometimes prepends to replies. */ -export function stripInternalDeliveryPlanning(text: string): string { - if (!text) return text; - const paragraphs = text.split(/\n{2,}/); - const kept = paragraphs.filter((paragraph) => !isInternalDeliveryPlanningParagraph(paragraph)); - if (kept.length === paragraphs.length) return text; - return kept - .join('\n\n') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -/** Normalize assistant reply text for chat bubbles and stream overrides. */ -export function sanitizeAssistantReplyText(text: string): string { - if (!text) return text; - return stripInternalSentinelLines( - stripAssistantMediaTags(stripInternalDeliveryPlanning(text)), - ); -} - -/** Process narration that should never appear in the execution graph or chat stream. */ -export function isInternalProcessNarration(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return true; - if (isInternalAssistantReplyText(trimmed)) return true; - if (isGeneratingStatusNarration(trimmed)) return true; - if (isOpenClawRuntimeEventPrompt(trimmed)) return true; - if (isInternalDeliveryPlanningParagraph(trimmed)) return true; - if (/^\[Inter-session message\]/i.test(trimmed)) return true; - if (/OpenClaw runtime event/i.test(trimmed)) return true; - if (/Handle the result internally/i.test(trimmed) && /Do not relay it to the user/i.test(trimmed)) { - return true; - } - return false; -} - -/** True when a renderer `src` cannot be fetched directly (Gateway proxy / local paths). */ -export function isUnresolvableImageUrl(url: string): boolean { - const trimmed = url.trim(); - if (!trimmed) return true; - if (trimmed.startsWith('data:')) return false; - if (/^https?:\/\//i.test(trimmed)) return false; - if (trimmed.startsWith('/api/chat/media/')) return true; - if (trimmed.startsWith('file://')) return true; - if (trimmed.includes('.openclaw/media/')) return true; - if (trimmed.startsWith('/') || trimmed.startsWith('~/')) return true; - return false; -} - -/** - * Extract displayable text from a message's content field. - * Handles both string content and array-of-blocks content. - * For user messages, strips Gateway-injected metadata. - */ -export function extractText(message: RawMessage | unknown): string { - if (!message || typeof message !== 'object') return ''; - const msg = message as Record; - const content = msg.content; - const isUser = msg.role === 'user'; - - let result = ''; - - if (typeof content === 'string') { - result = content.trim().length > 0 ? content : ''; - } else if (Array.isArray(content)) { - const parts: string[] = []; - for (const block of content as ContentBlock[]) { - if (block.type === 'text' && block.text) { - if (block.text.trim().length > 0) { - parts.push(block.text); - } - } - } - const combined = compactProgressiveParts(parts).join('\n\n'); - result = combined.trim().length > 0 ? combined : ''; - } else if (typeof msg.text === 'string') { - // Fallback: try .text field - result = msg.text.trim().length > 0 ? msg.text : ''; - } - - // Strip Gateway metadata from user messages for clean display - if (isUser && result) { - result = cleanUserText(result); - } else if (!isUser && result) { - if (isInternalAssistantReplyText(result)) return ''; - if (isGeneratingStatusNarration(result)) return ''; - // Assistant-side cleanup: drop internal delivery planning, `MEDIA:/path` - // tags, and trailing sentinels. Artifact paths surface via `_attachedFiles`. - result = sanitizeAssistantReplyText(result); - } - - return result; -} - -export function extractTextSegments(message: RawMessage | unknown): string[] { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - const content = msg.content; - const isUser = msg.role === 'user'; - - let segments: string[] = []; - - if (typeof content === 'string') { - const cleaned = content.trim(); - segments = cleaned ? [cleaned] : []; - } else if (Array.isArray(content)) { - const parts: string[] = []; - for (const block of content as ContentBlock[]) { - if (block.type === 'text' && block.text && block.text.trim()) { - parts.push(block.text); - } - } - segments = splitProgressiveParts(parts); - } else if (typeof msg.text === 'string') { - const cleaned = msg.text.trim(); - segments = cleaned ? [cleaned] : []; - } - - if (!isUser) { - return segments - .map((segment) => stripInternalSentinelLines(stripAssistantMediaTags(segment))) - .filter((segment) => segment.length > 0); - } - - return segments - .map((segment) => cleanUserText(segment)) - .filter((segment) => segment.length > 0); -} - -/** - * Extract thinking/reasoning content from a message. - * Returns null if no thinking content found. - */ -export function extractThinking(message: RawMessage | unknown): string | null { - if (!message || typeof message !== 'object') return null; - const msg = message as Record; - const content = msg.content; - - if (!Array.isArray(content)) return null; - - const parts: string[] = []; - for (const block of content as ContentBlock[]) { - if (block.type === 'thinking' && block.thinking) { - const cleaned = block.thinking.trim(); - if (cleaned) { - parts.push(cleaned); - } - } - } - - const combined = compactProgressiveParts(parts).join('\n\n').trim(); - return combined.length > 0 ? combined : null; -} - -export function extractThinkingSegments(message: RawMessage | unknown): string[] { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - const content = msg.content; - - if (!Array.isArray(content)) return []; - - const parts: string[] = []; - for (const block of content as ContentBlock[]) { - if (block.type === 'thinking' && block.thinking) { - const cleaned = block.thinking.trim(); - if (cleaned) { - parts.push(cleaned); - } - } - } - - return splitProgressiveParts(parts); -} - -export function stripProcessMessagePrefix(text: string, processSegments: string[]): string { - let remaining = text; - let strippedAny = false; - - for (const segment of processSegments) { - const normalizedSegment = normalizeProgressiveText(segment); - if (!normalizedSegment) continue; - const consumed = consumeLeadingSegment(remaining, normalizedSegment); - if (consumed === 0) break; - remaining = remaining.slice(consumed); - strippedAny = true; - } - - const trimmed = remaining.trimStart(); - return strippedAny && trimmed ? trimmed : text; -} - -/** - * Extract media file references from Gateway-formatted user message text. - * Returns array of { filePath, mimeType } from [media attached: path (mime) | path] patterns. - */ -export function extractMediaRefs(message: RawMessage | unknown): Array<{ filePath: string; mimeType: string }> { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - if (msg.role !== 'user') return []; - const content = msg.content; - - let text = ''; - if (typeof content === 'string') { - text = content; - } else if (Array.isArray(content)) { - text = (content as ContentBlock[]) - .filter(b => b.type === 'text' && b.text) - .map(b => b.text!) - .join('\n'); - } - - const refs: Array<{ filePath: string; mimeType: string }> = []; - const regex = /\[media attached:\s*([^\s(]+)\s*\(([^)]+)\)\s*\|[^\]]*\]/g; - let match; - while ((match = regex.exec(text)) !== null) { - refs.push({ filePath: match[1], mimeType: match[2] }); - } - return refs; -} - -/** - * Extract inline image attachments from a message. - * - * Returns either: - * - `{ mimeType, data }` — base64 bytes inline (Anthropic / Gateway tool-result) - * - `{ mimeType, url }` — Anthropic source-wrapped remote URL form - * - * Note: the Gateway-injected `assistant-media` shape — flat - * `{ type:'image', url:'/api/chat/media/outgoing/...', mimeType, ... }` — - * is intentionally NOT extracted here because the URL is a Gateway-relative - * path the renderer cannot fetch directly (CORS / env drift). That shape - * is surfaced through `_attachedFiles` instead (see - * `extractImagesAsAttachedFiles` and `enrichWithGatewayMediaBlocks` in - * `src/stores/chat/helpers.ts`), and resolved to a local preview by - * `loadMissingPreviews` -> Main `media:getThumbnails`. - */ -export function extractImages( - message: RawMessage | unknown, -): Array<{ mimeType: string; data?: string; url?: string }> { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - const content = msg.content; - - if (!Array.isArray(content)) return []; - - const images: Array<{ mimeType: string; data?: string; url?: string }> = []; - for (const block of content as ContentBlock[]) { - if (block.type !== 'image') continue; - - // Path 1: Anthropic source-wrapped format - if (block.source) { - const src = block.source; - if (src.type === 'base64' && src.media_type && src.data) { - images.push({ mimeType: src.media_type, data: src.data }); - } else if (src.type === 'url' && src.url && !isUnresolvableImageUrl(src.url)) { - images.push({ mimeType: src.media_type || 'image/jpeg', url: src.url }); - } - continue; - } - - // Path 2: Flat format from Gateway tool results {data, mimeType} - if (block.data) { - images.push({ mimeType: block.mimeType || 'image/jpeg', data: block.data }); - } - // Flat `block.url` is intentionally NOT handled here; see comment above. - } - - return images; -} - -/** Normalize Gateway/OpenClaw message roles for comparisons. */ -export function normalizeMessageRole(role: unknown): string { - return typeof role === 'string' ? role.trim().toLowerCase() : ''; -} - -/** - * Extract tool use blocks from a message. - * Handles both Anthropic format (tool_use in content array) and - * OpenAI format (tool_calls array on the message object). - */ -export function extractToolUse(message: RawMessage | unknown): Array<{ id: string; name: string; input: unknown }> { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - const tools: Array<{ id: string; name: string; input: unknown }> = []; - - // Path 1: Anthropic/normalized format — tool_use / toolCall blocks inside content array - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if ((block.type === 'tool_use' || block.type === 'toolCall') && block.name) { - tools.push({ - id: block.id || '', - name: block.name, - input: block.input ?? block.arguments, - }); - } - } - } - - // Path 2: OpenAI format — tool_calls array on the message itself - // Real-time streaming events from OpenAI-compatible models (DeepSeek, etc.) - // use this format; the Gateway normalizes to Path 1 when storing history. - if (tools.length === 0) { - const toolCalls = msg.tool_calls ?? msg.toolCalls; - if (Array.isArray(toolCalls)) { - for (const tc of toolCalls as Array>) { - const fn = (tc.function ?? tc) as Record; - const name = typeof fn.name === 'string' ? fn.name : ''; - if (!name) continue; - let input: unknown; - try { - input = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : fn.arguments ?? fn.input; - } catch { - input = fn.arguments; - } - tools.push({ - id: typeof tc.id === 'string' ? tc.id : '', - name, - input, - }); - } - } - } - - return tools; -} - -/** - * Format a Unix timestamp (seconds) to relative time string. - */ -export function formatTimestamp(timestamp: unknown): string { - if (!timestamp) return ''; - const ts = typeof timestamp === 'number' ? timestamp : Number(timestamp); - if (!ts || isNaN(ts)) return ''; - - // OpenClaw timestamps can be in seconds or milliseconds - const ms = ts > 1e12 ? ts : ts * 1000; - const date = new Date(ms); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - - if (diffMs < 60000) return 'just now'; - if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`; - if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`; - - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} diff --git a/src/pages/Chat/task-visualization.ts b/src/pages/Chat/task-visualization.ts deleted file mode 100644 index c01cbfb5..00000000 --- a/src/pages/Chat/task-visualization.ts +++ /dev/null @@ -1,598 +0,0 @@ -// Legacy Gateway Chat visualization retained only for tests or non-ACP migration cleanup. -// ACP Chat renders ordered inline timeline blocks instead. -import { - extractImages, - extractText, - extractTextSegments, - extractToolUse, - isGeneratingStatusNarration, - isInternalAssistantReplyText, - isInternalProcessNarration, -} from './message-utils'; -import { isInternalMessage } from '@/stores/chat/helpers'; -import type { RawMessage, ToolStatus } from '@/stores/chat'; -import type { ChatRuntimeRunState } from '@/stores/chat/types'; - -export type TaskStepStatus = 'running' | 'completed' | 'error'; - -export interface TaskStep { - id: string; - label: string; - status: TaskStepStatus; - kind: 'thinking' | 'tool' | 'system' | 'message'; - detail?: string; - depth: number; - parentId?: string; - /** Extracted URL for web_fetch tool, used to render a clickable link icon. */ - url?: string; -} - -/** - * Detects the index of the "final reply" assistant message in a run segment. - * - * The reply is the last assistant message that carries non-empty text - * content, regardless of whether it ALSO carries tool calls. (Mixed - * `text + toolCall` replies are rare but real — the model can emit a parting - * text block alongside a final tool call. Treating such a message as the - * reply avoids mis-protecting an earlier narration as the "answer" and - * leaking the actual last text into the fold.) - * - * When this returns a non-negative index, the caller should avoid folding - * that message's text into the graph (it is the answer the user sees in the - * chat stream). When the run is still active (streaming) the final reply is - * produced via `streamingMessage` instead, so callers pass - * `hasStreamingReply = true` to skip protection and let every assistant-with- - * text message in history be folded into the graph as narration. - */ -export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply: boolean): number { - if (hasStreamingReply) return -1; - for (let idx = messages.length - 1; idx >= 0; idx -= 1) { - const message = messages[idx]; - if (!message || message.role !== 'assistant') continue; - const replyText = extractText(message).trim(); - if (messageHasUserVisibleImage(message)) return idx; - if (replyText.length === 0 || isInternalAssistantReplyText(replyText)) continue; - if (isGeneratingStatusNarration(replyText)) continue; - return idx; - } - return -1; -} - -function messageHasUserVisibleImage(message: RawMessage): boolean { - if ((message._attachedFiles ?? []).some((file) => file.mimeType.startsWith('image/'))) { - return true; - } - return extractImages(message).length > 0; -} - -/** - * When true, assistant history in the run segment should be folded into the - * execution graph because the live answer is (or will be) shown via streaming. - * When false but the run is still open, a final reply already in history must - * stay visible in the chat stream (history poll can beat stream teardown). - */ -export function hasActiveStreamingReplyInRun( - isLatestOpenRun: boolean, - hasAnyStreamContent: boolean, - streamingReplyText: string | null, -): boolean { - return isLatestOpenRun && (hasAnyStreamContent || streamingReplyText != null); -} - -/** - * Message indices that belong to an agent run segment (strictly after a run - * trigger user message up to the next real user message). Used to fold tool - * cards and process attachments into ExecutionGraphCard without depending on - * whether a graph card was successfully materialized (e.g. after history - * reload when the step cache is empty). - */ -export function buildRunSegmentMessageIndices( - messages: RawMessage[], - nextUserMessageIndexes: number[], - isRunTrigger: (message: RawMessage, index: number) => boolean, -): Set { - const indices = new Set(); - messages.forEach((message, triggerIndex) => { - if (!isRunTrigger(message, triggerIndex)) return; - const nextUserIndex = nextUserMessageIndexes[triggerIndex]; - const segmentEnd = nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1; - for (let idx = triggerIndex + 1; idx <= segmentEnd; idx += 1) { - indices.add(idx); - } - }); - - // History pagination loads a suffix of the transcript. When the triggering - // user turn fell off the window, assistant tool steps remain at the top of - // `messages[]` without a preceding user row — fold them into the first run. - let firstTriggerIndex = -1; - for (let idx = 0; idx < messages.length; idx += 1) { - if (isRunTrigger(messages[idx], idx)) { - firstTriggerIndex = idx; - break; - } - } - if (firstTriggerIndex > 0) { - for (let idx = 0; idx < firstTriggerIndex; idx += 1) { - if (messages[idx]?.role === 'assistant') { - indices.add(idx); - } - } - } - - return indices; -} - -/** - * Messages strictly after the triggering user turn up to the next user. - * Use this for run lifecycle (final reply detection, reply index, open-run - * state) — never count paginated orphan assistants from a prior turn. - */ -export function getPostTriggerSegmentMessages( - messages: RawMessage[], - triggerIndex: number, - nextUserIndex: number, -): RawMessage[] { - const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex; - return messages.slice(triggerIndex + 1, segmentEnd); -} - -/** - * Slice messages for a user-triggered run, including leading assistant orphans - * that belong to the same run but were separated by paginated history. - */ -export function getRunSegmentMessages( - messages: RawMessage[], - triggerIndex: number, - nextUserIndex: number, - isRunTrigger: (message: RawMessage, index: number) => boolean, -): RawMessage[] { - const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex; - const core = messages.slice(triggerIndex + 1, segmentEnd); - const hasEarlierUser = messages.some((message, index) => index < triggerIndex && isRunTrigger(message, index)); - if (hasEarlierUser || triggerIndex === 0) return core; - const orphans = messages.slice(0, triggerIndex).filter((message) => message.role === 'assistant'); - return [...orphans, ...core]; -} - -/** - * True when a run segment already contains a conclusive assistant reply: the - * last assistant message with user-visible text that appears after all tool - * calls (if any). Intermediate narration before tools does not count. - */ -export function segmentHasFinalReply(segmentMessages: RawMessage[]): boolean { - let lastToolUseOffset = -1; - for (let i = segmentMessages.length - 1; i >= 0; i -= 1) { - const message = segmentMessages[i]; - if (message.role === 'assistant' && extractToolUse(message).length > 0) { - lastToolUseOffset = i; - break; - } - } - return segmentMessages.some((message, index) => { - if (index <= lastToolUseOffset) return false; - if (message.role !== 'assistant') return false; - if (messageHasUserVisibleImage(message)) return true; - const replyText = extractText(message).trim(); - if (replyText.length === 0 || isInternalAssistantReplyText(replyText)) return false; - if (isGeneratingStatusNarration(replyText)) return false; - const content = message.content; - if (!Array.isArray(content)) return true; - return !(content as Array<{ type?: string }>).some( - (block) => block.type === 'tool_use' || block.type === 'toolCall', - ); - }); -} - -interface DeriveTaskStepsInput { - messages: RawMessage[]; - streamingMessage: unknown | null; - streamingTools: ToolStatus[]; - omitLastStreamingMessageSegment?: boolean; -} - -export interface SubagentCompletionInfo { - sessionKey: string; - sessionId: string; - agentId: string; -} - -function normalizeText(text: string | null | undefined): string | undefined { - if (!text) return undefined; - const normalized = text.replace(/[ \t]+/g, ' ').trim(); - if (!normalized) return undefined; - return normalized; -} - -function makeToolId(prefix: string, name: string, index: number): string { - return `${prefix}:${name}:${index}`; -} - -export function parseAgentIdFromSessionKey(sessionKey: string): string | null { - const parts = sessionKey.split(':'); - if (parts.length < 2 || parts[0] !== 'agent') return null; - return parts[1] || null; -} - -export function parseSubagentCompletionInfo(message: RawMessage): SubagentCompletionInfo | null { - const text = typeof message.content === 'string' - ? message.content - : Array.isArray(message.content) - ? message.content.map((block) => ('text' in block && typeof block.text === 'string' ? block.text : '')).join('\n') - : ''; - if (!text.includes('[Internal task completion event]')) return null; - - const sessionKeyMatch = text.match(/session_key:\s*(.+)/); - const sessionIdMatch = text.match(/session_id:\s*(.+)/); - const sessionKey = sessionKeyMatch?.[1]?.trim(); - const sessionId = sessionIdMatch?.[1]?.trim(); - if (!sessionKey || !sessionId) return null; - const agentId = parseAgentIdFromSessionKey(sessionKey); - if (!agentId) return null; - return { sessionKey, sessionId, agentId }; -} - -function isSpawnLikeStep(label: string): boolean { - return /(spawn|subagent|delegate|parallel)/i.test(label); -} - -function tryParseJsonObject(detail: string | undefined): Record | null { - if (!detail) return null; - try { - const parsed = JSON.parse(detail) as unknown; - return parsed && typeof parsed === 'object' ? parsed as Record : null; - } catch { - return null; - } -} - -function extractBranchAgent(step: TaskStep): string | null { - const parsed = tryParseJsonObject(step.detail); - const agentId = parsed?.agentId; - if (typeof agentId === 'string' && agentId.trim()) return agentId.trim(); - - const message = typeof parsed?.message === 'string' ? parsed.message : step.detail; - if (!message) return null; - const match = message.match(/\b(coder|reviewer|project-manager|manager|planner|researcher|worker|subagent)\b/i); - return match ? match[1] : null; -} - -function attachTopology(steps: TaskStep[]): TaskStep[] { - const withTopology: TaskStep[] = []; - let activeBranchNodeId: string | null = null; - - for (const step of steps) { - if (step.kind === 'system') { - activeBranchNodeId = null; - withTopology.push({ ...step, depth: 1, parentId: 'agent-run' }); - continue; - } - - if (/sessions_spawn/i.test(step.label)) { - const branchAgent = extractBranchAgent(step) || 'subagent'; - const branchNodeId = `${step.id}:branch`; - withTopology.push({ ...step, depth: 1, parentId: 'agent-run' }); - withTopology.push({ - id: branchNodeId, - label: `${branchAgent} run`, - status: step.status, - kind: 'system', - detail: `Spawned branch for ${branchAgent}`, - depth: 2, - parentId: step.id, - }); - activeBranchNodeId = branchNodeId; - continue; - } - - if (/sessions_yield/i.test(step.label)) { - withTopology.push({ - ...step, - depth: activeBranchNodeId ? 3 : 1, - parentId: activeBranchNodeId ?? 'agent-run', - }); - activeBranchNodeId = null; - continue; - } - - if (step.kind === 'thinking' || step.kind === 'message') { - withTopology.push({ - ...step, - depth: activeBranchNodeId ? 3 : 1, - parentId: activeBranchNodeId ?? 'agent-run', - }); - continue; - } - - if (isSpawnLikeStep(step.label)) { - activeBranchNodeId = step.id; - withTopology.push({ - ...step, - depth: 1, - parentId: 'agent-run', - }); - continue; - } - - withTopology.push({ - ...step, - depth: activeBranchNodeId ? 3 : 1, - parentId: activeBranchNodeId ?? 'agent-run', - }); - } - - return withTopology; -} - -function appendDetailSegments( - segments: string[], - options: { - idPrefix: string; - label: string; - kind: Extract; - running: boolean; - upsertStep: (step: TaskStep) => void; - }, -): void { - const normalizedSegments = segments - .map((segment) => normalizeText(segment)) - .filter((segment): segment is string => !!segment) - .filter((segment) => !isInternalProcessNarration(segment)); - - normalizedSegments.forEach((detail, index) => { - options.upsertStep({ - id: `${options.idPrefix}-${index}`, - label: options.label, - status: options.running && index === normalizedSegments.length - 1 ? 'running' : 'completed', - kind: options.kind, - detail, - depth: 1, - }); - }); -} - -function runtimeDetail(value: unknown): string | undefined { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed || undefined; - } - if (value == null) return undefined; - try { - const rendered = JSON.stringify(value, null, 2); - return rendered.length > 4000 ? `${rendered.slice(0, 4000)}…` : rendered; - } catch { - return String(value); - } -} - -export function deriveRuntimeTaskSteps(runState: ChatRuntimeRunState | null | undefined): TaskStep[] { - if (!runState) return []; - - const steps: TaskStep[] = []; - const stepIndexById = new Map(); - const upsertStep = (step: TaskStep): void => { - const existingIndex = stepIndexById.get(step.id); - if (existingIndex == null) { - stepIndexById.set(step.id, steps.length); - steps.push(step); - return; - } - const existing = steps[existingIndex]; - steps[existingIndex] = { - ...existing, - ...step, - detail: step.detail ?? existing.detail, - url: step.url ?? existing.url, - }; - }; - - for (const event of runState.events) { - switch (event.type) { - case 'tool.started': { - const input = event.args as Record | undefined; - const url = event.name === 'web_fetch' && typeof input?.url === 'string' ? input.url : undefined; - upsertStep({ - id: event.toolCallId, - label: event.name, - status: 'running', - kind: 'tool', - detail: runtimeDetail(event.args), - depth: 1, - url, - }); - break; - } - case 'tool.updated': { - upsertStep({ - id: event.toolCallId, - label: event.name, - status: 'running', - kind: 'tool', - detail: runtimeDetail(event.partialResult), - depth: 1, - }); - break; - } - case 'tool.completed': { - upsertStep({ - id: event.toolCallId, - label: event.name, - status: event.isError ? 'error' : 'completed', - kind: 'tool', - detail: runtimeDetail(event.result), - depth: 1, - }); - break; - } - case 'command.output': { - const id = event.itemId || `${event.toolCallId || event.name || 'command'}:output`; - upsertStep({ - id, - label: event.title || `${event.name || 'Command'} output`, - status: event.status === 'failed' || event.status === 'error' ? 'error' : event.phase === 'end' ? 'completed' : 'running', - kind: 'message', - detail: runtimeDetail(event.output), - depth: 1, - }); - break; - } - case 'patch.completed': { - const id = event.itemId || `${event.toolCallId || event.name || 'patch'}:patch`; - upsertStep({ - id, - label: event.title || event.name || 'Patch', - status: 'completed', - kind: 'system', - detail: runtimeDetail(event.summary), - depth: 1, - }); - break; - } - case 'approval.updated': { - const id = event.itemId || `${event.toolCallId || 'approval'}:approval`; - upsertStep({ - id, - label: event.title || 'Approval', - status: event.status === 'denied' || event.status === 'failed' ? 'error' : event.phase === 'resolved' ? 'completed' : 'running', - kind: 'system', - detail: runtimeDetail(event.message), - depth: 1, - }); - break; - } - default: - break; - } - } - - return attachTopology(steps); -} - -export function deriveTaskSteps({ - messages, - streamingMessage, - streamingTools, - omitLastStreamingMessageSegment = false, -}: DeriveTaskStepsInput): TaskStep[] { - const steps: TaskStep[] = []; - const stepIndexById = new Map(); - - const upsertStep = (step: TaskStep): void => { - const existingIndex = stepIndexById.get(step.id); - if (existingIndex == null) { - stepIndexById.set(step.id, steps.length); - steps.push(step); - return; - } - const existing = steps[existingIndex]; - steps[existingIndex] = { - ...existing, - ...step, - detail: step.detail ?? existing.detail, - }; - }; - - const streamMessage = streamingMessage && typeof streamingMessage === 'object' - ? streamingMessage as RawMessage - : null; - - // The final answer the user sees as a chat bubble. We avoid folding it into - // the graph to prevent duplication. When a run is still streaming, the - // reply lives in `streamingMessage`, so every pure-text assistant message in - // `messages` is treated as intermediate narration. - const replyIndex = findReplyMessageIndex(messages, streamMessage != null); - - for (const [messageIndex, message] of messages.entries()) { - if (!message || message.role !== 'assistant') continue; - - const toolUses = extractToolUse(message); - if (!isInternalMessage(message)) { - // Fold any intermediate assistant text into the graph as a narration - // step — including text that lives on a mixed `text + toolCall` message. - // The narration step is emitted BEFORE the tool steps so the graph - // preserves the original ordering (the assistant "thinks out loud" and - // then invokes the tool). - const narrationSegments = extractTextSegments(message); - const graphNarrationSegments = messageIndex === replyIndex - ? narrationSegments.slice(0, -1) - : narrationSegments; - appendDetailSegments(graphNarrationSegments, { - idPrefix: `history-message-${message.id || messageIndex}`, - label: 'Message', - kind: 'message', - running: false, - upsertStep, - }); - } else if (toolUses.length === 0) { - continue; - } - - toolUses.forEach((tool, index) => { - const input = tool.input as Record; - const url = tool.name === 'web_fetch' && typeof input?.url === 'string' ? input.url : undefined; - upsertStep({ - id: tool.id || makeToolId(`history-tool-${message.id || messageIndex}`, tool.name, index), - label: tool.name, - status: 'completed', - kind: 'tool', - detail: normalizeText(JSON.stringify(tool.input, null, 2)), - depth: 1, - url, - }); - }); - } - - if (streamMessage) { - // Stream-time narration should also appear in the execution graph so that - // intermediate process output stays in P1 instead of leaking into the - // assistant reply area. - const streamNarrationSegments = extractTextSegments(streamMessage); - const graphStreamNarrationSegments = omitLastStreamingMessageSegment - ? streamNarrationSegments.slice(0, -1) - : streamNarrationSegments; - appendDetailSegments(graphStreamNarrationSegments, { - idPrefix: 'stream-message', - label: 'Message', - kind: 'message', - running: !omitLastStreamingMessageSegment, - upsertStep, - }); - } - - const activeToolIds = new Set(); - const activeToolNamesWithoutIds = new Set(); - streamingTools.forEach((tool, index) => { - const id = tool.toolCallId || tool.id || makeToolId('stream-status', tool.name, index); - activeToolIds.add(id); - if (!tool.toolCallId && !tool.id) { - activeToolNamesWithoutIds.add(tool.name); - } - upsertStep({ - id, - label: tool.name, - status: tool.status, - kind: 'tool', - detail: normalizeText(tool.summary), - depth: 1, - }); - }); - - if (streamMessage) { - extractToolUse(streamMessage).forEach((tool, index) => { - const id = tool.id || makeToolId('stream-tool', tool.name, index); - if (activeToolIds.has(id) || activeToolNamesWithoutIds.has(tool.name)) return; - const input = tool.input as Record; - const url = tool.name === 'web_fetch' && typeof input?.url === 'string' ? input.url : undefined; - upsertStep({ - id, - label: tool.name, - status: 'running', - kind: 'tool', - detail: normalizeText(JSON.stringify(tool.input, null, 2)), - depth: 1, - url, - }); - }); - } - - return attachTopology(steps); -} diff --git a/src/stores/baseline-cache.ts b/src/stores/baseline-cache.ts deleted file mode 100644 index 39d9405b..00000000 --- a/src/stores/baseline-cache.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Per-run baseline content cache for file diff computation. - * - * When the AI issues a Write-family tool_use, we read the target file from - * disk *before* the runtime actually writes it, so we have the "before" - * content to render a proper before/after diff. Cache entries are scoped to a - * single run (represented by a stable run key derived from the triggering user - * message), not shared across the whole session — otherwise a later run that - * edits the same path would incorrectly diff against an older baseline. - */ -import { readTextFile, type FilePreviewError } from '@/lib/file-preview-client'; -import type { GeneratedFileBaseline } from '@/lib/generated-files'; - -const KEY_SEPARATOR = '\u0000'; -const cache = new Map(); -const inflight = new Map>(); - -function makeCompositeKey(runKey: string, filePath: string): string { - return `${runKey}${KEY_SEPARATOR}${filePath}`; -} - -/** - * Build a stable per-run key from the session + user-turn ordinal. - * - * We intentionally avoid Gateway `runId` (not present after history reload) - * and timestamps (the optimistic local user message timestamp can differ from - * the persisted history timestamp). The nth real user turn inside a session is - * stable across streaming and post-final history reloads, which makes it a - * reliable join key for captured baselines. - */ -export function buildBaselineRunKey(sessionKey: string, userTurnOrdinal: number): string | null { - const normalizedSessionKey = sessionKey.trim(); - if (!normalizedSessionKey) return null; - if (!Number.isFinite(userTurnOrdinal) || userTurnOrdinal <= 0) return null; - return `${normalizedSessionKey}|turn:${userTurnOrdinal}`; -} - -export function getBaseline(runKey: string, filePath: string): GeneratedFileBaseline | undefined { - return cache.get(makeCompositeKey(runKey, filePath)); -} - -export function hasBaseline(runKey: string, filePath: string): boolean { - return cache.has(makeCompositeKey(runKey, filePath)); -} - -function unavailable(reason: FilePreviewError | string | undefined): GeneratedFileBaseline { - return { status: 'unavailable', reason: String(reason ?? 'unknown') }; -} - -/** - * Capture the baseline for `filePath` inside `runKey` if we haven't already. - * Returns immediately — the IPC read runs in the background. - */ -export function captureBaseline(runKey: string, filePath: string): void { - if (!runKey || !filePath) return; - - const key = makeCompositeKey(runKey, filePath); - if (cache.has(key) || inflight.has(key)) return; - - const promise = (async () => { - try { - const result = await readTextFile(filePath); - if (result.ok && typeof result.content === 'string') { - cache.set(key, { status: 'ok', content: result.content }); - } else if (result.error === 'notFound') { - cache.set(key, { status: 'missing' }); - } else { - cache.set(key, unavailable(result.error)); - } - } catch (error) { - cache.set(key, unavailable(error instanceof Error ? error.message : String(error))); - } finally { - inflight.delete(key); - } - })(); - - inflight.set(key, promise); -} - -export function clearBaselinesForRun(runKey: string): void { - if (!runKey) return; - for (const key of cache.keys()) { - if (key.startsWith(`${runKey}${KEY_SEPARATOR}`)) { - cache.delete(key); - } - } - for (const key of inflight.keys()) { - if (key.startsWith(`${runKey}${KEY_SEPARATOR}`)) { - inflight.delete(key); - } - } -} - -/** Clear all cached baselines (call on session switch). */ -export function clearBaselines(): void { - cache.clear(); - inflight.clear(); -} diff --git a/src/stores/chat.ts b/src/stores/chat.ts index dd5c8791..1079bb79 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1,54 +1,27 @@ -/** - * Chat State Store - * Manages chat messages, sessions, and streaming state. - * Communicates with OpenClaw Gateway through the Main-owned host API. - */ -import { create } from 'zustand'; -import { hostApi, type ChatSendWithMediaResult, type SessionLabelSummary } from '@/lib/host-api'; +import { create, type StoreApi } from 'zustand'; +import { hostApi, type SessionLabelSummary } from '@/lib/host-api'; import { isAcpWorkingDirectoryTruncatedTitle, isOpenClawSessionIdFallbackTitle, stripAcpWorkingDirectoryPrefix, } from '@shared/chat/session-title'; -import { useGatewayStore } from './gateway'; -import { useAgentsStore } from './agents'; -import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events'; -import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache'; -import { isCronSessionKey, sessionKeysAreEquivalent } from './chat/cron-session-utils'; -import { - findHiddenOpenClawHeartbeatSession, - isClawXDesktopSessionKey, - isOpenClawHeartbeatOnlySession, - shouldIncludeSessionInSidebarList, -} from './chat/session-key-utils'; import { isOpenClawHeartbeatPollText, OPENCLAW_HEARTBEAT_POLL_SENTINEL, } from '@shared/chat/openclaw-internal'; -import { fetchCronSessionHistory } from '@/lib/cron-session-history'; +import { useGatewayStore } from './gateway'; import { pickStartupSessionFallback } from './chat/session-selection'; import { applyGatewaySessionsChanged, normalizeGatewaySessionRow, type GatewaySessionsChangedPayload, } from './chat/session-catalog'; -import { useSessionAttentionStore, type SessionAttentionTransition } from './session-attention'; import { - CHAT_HISTORY_DISK_FALLBACK_TIMEOUT_MS, - CHAT_HISTORY_STARTUP_FALLBACK_RACE_MS, - CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS, - classifyHistoryStartupRetryError, - getHistoryLoadingSafetyTimeout, - getStartupHistoryTimeoutOverride, - shouldRetryStartupHistoryLoad, - sleep, -} from './chat/history-startup-retry'; -import { - buildChatHistoryRpcParams, - getChatHistoryMaxChars, -} from './chat/history-rpc-params'; -import { loadSessionTranscriptFallback } from './chat/history-transcript-fallback'; -import { hydrateGatewayHistoryFromTranscript } from './chat/history-transcript-hydrate'; + findHiddenOpenClawHeartbeatSession, + isClawXDesktopSessionKey, + isOpenClawHeartbeatOnlySession, + shouldIncludeSessionInSidebarList, +} from './chat/session-key-utils'; import { LABEL_FETCH_RETRY_DELAYS_MS, abandonSessionLabelHydration, @@ -59,138 +32,49 @@ import { getSessionLabelHydrationVersion, isSessionLabelHydrationVersionCurrent, } from './chat/session-label-hydration'; +import { useSessionAttentionStore, type SessionAttentionTransition } from './session-attention'; import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, - type AttachedFileMeta, type ChatSession, type ChatState, - type ContentBlock, - type RawMessage, - type ToolStatus, -} from './chat/types'; -import type { ChatGet, ChatSet } from './chat/store-api'; -import { applyRuntimeEventToRuns, extractToolCompletedFiles } from './chat/runtime-graph'; -import { enrichWithToolCallAttachments, shouldDropMessageFromHistory } from './chat/helpers'; -import { - isGeneratingStatusNarration, - isInternalAssistantReplyText, - isOpenClawRuntimeEventPrompt, -} from '@/pages/Chat/message-utils'; - -export type { - AttachedFileMeta, - ChatSession, - ContentBlock, - RawMessage, - ToolStatus, - ChatRuntimeRunState, } from './chat/types'; -// Module-level timestamp tracking the last chat event received. -// Used by the safety timeout to avoid false-positive "no response" errors -// during tool-use conversations where streamingMessage is temporarily cleared -// between tool-result finals and the next delta. -let _lastChatEventAt = 0; +export type { ChatSession } from './chat/types'; -/** Normalize a timestamp to milliseconds. Handles both seconds and ms. */ -function toMs(ts: number): number { - // Timestamps < 1e12 are in seconds (before ~2033); >= 1e12 are milliseconds - return ts < 1e12 ? ts * 1000 : ts; +type ChatSet = StoreApi['setState']; + +let loadSessionsInFlight: Promise | null = null; +let lastLoadSessionsAt = 0; +let sessionCatalogGeneration = 0; +let loadSessionsContext: { + generation: number; + events: GatewaySessionsChangedPayload[]; +} | null = null; +let pendingGenerationSessionEvents: { + generation: number; + events: GatewaySessionsChangedPayload[]; +} | null = null; +let queuedForcedSessionGeneration: number | null = null; +let standaloneForcedSessionReloadPending = false; +const latestSessionEventTsByKey = new Map(); +let successfulSessionListTsFloor: number | null = null; +let localSessionCatalogRevision = 0; +const pendingCatalogConfirmationSessionKeys = new Set(); +const localDraftSessionKeys = new Set(); + +const SESSION_LOAD_MIN_INTERVAL_MS = 1_200; + +function markLocalSessionCatalogMutation(): void { + localSessionCatalogRevision += 1; } -// Timer for fallback history polling during active sends. -// If no streaming events arrive within a few seconds, we periodically -// poll chat.history to surface intermediate tool-call turns. -let _historyPollTimer: ReturnType | null = null; - -// Timer for delayed error finalization. When the Gateway reports a mid-stream -// error (e.g. "terminated"), it may retry internally and recover. We wait -// before committing the error to give the recovery path a chance. -let _errorRecoveryTimer: ReturnType | null = null; -let _loadSessionsInFlight: Promise | null = null; -let _lastLoadSessionsAt = 0; -let _sessionCatalogGeneration = 0; -let _loadSessionsContext: { - generation: number; - events: GatewaySessionsChangedPayload[]; -} | null = null; -let _pendingGenerationSessionEvents: { - generation: number; - events: GatewaySessionsChangedPayload[]; -} | null = null; -let _queuedForcedSessionGeneration: number | null = null; -let _standaloneForcedSessionReloadPending = false; -const _latestSessionEventTsByKey = new Map(); -let _successfulSessionListTsFloor: number | null = null; -const _historyLoadInFlight = new Map>(); -const _lastHistoryLoadAtBySession = new Map(); -const _forceNextHistoryLoadBySession = new Set(); -const _foregroundHistoryLoadSeen = new Set(); -const _sessionHistoryCache = new Map(); - -type SessionRunState = Pick< - ChatState, - | 'sending' - | 'activeRunId' - | 'pendingFinal' - | 'lastUserMessageAt' - | 'streamingText' - | 'streamingMessage' - | 'streamingTools' - | 'pendingToolImages' ->; - -const DEFAULT_SESSION_RUN_STATE: SessionRunState = { - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingToolImages: [], -}; - -const _sessionRunStateCache = new Map(); -let _sendGenerationCounter = 0; -const _activeSendGenerationBySession = new Map(); -const SESSION_LOAD_MIN_INTERVAL_MS = 1_200; -const HISTORY_LOAD_MIN_INTERVAL_MS = 800; -const CHAT_EVENT_DEDUPE_TTL_MS = 30_000; -const HISTORY_PAGE_SIZE = 200; -const HISTORY_MAX_RENDERED_MESSAGES = 1_000; -const _chatEventDedupe = new Map(); -const OPTIMISTIC_USER_MESSAGE_TTL_MS = 30 * 60 * 1000; -/** Max skew between the renderer optimistic send time and Gateway transcript timestamps. */ -const OPTIMISTIC_USER_TIMESTAMP_MATCH_MS = 120_000; -/** Grace period before surfacing mid-run Gateway errors that often self-recover. */ -const ERROR_RECOVERY_DELAY_MS = 12_000; -/** OpenClaw LLM idle timeout before an internal retry. */ -const LLM_IDLE_HINT_MS = 120_000; -/** Wait past one LLM idle window before declaring a hard no-response failure. */ -const NO_RESPONSE_SAFETY_TIMEOUT_MS = 130_000; -/** Delay before the first fallback transcript poll after a send. */ -const HISTORY_POLL_START_DELAY_MS = 3_000; -/** Interval between fallback transcript poll ticks during an active send. */ -const HISTORY_POLL_INTERVAL_MS = 5_000; -/** Only issue the fallback poll RPC after this much streamed-event silence. */ -const HISTORY_POLL_EVENT_SILENCE_MS = 10_000; - -type PendingOptimisticUserMessage = { - message: RawMessage; - timestampMs: number; - createdAtMs: number; -}; - -const _pendingOptimisticUserMessages = new Map(); - export function synchronizeGatewaySessionGeneration(generation: number): void { - if (!Number.isFinite(generation) || generation <= _sessionCatalogGeneration) return; - _sessionCatalogGeneration = generation; - _latestSessionEventTsByKey.clear(); - _successfulSessionListTsFloor = null; - _pendingGenerationSessionEvents = { generation, events: [] }; + if (!Number.isFinite(generation) || generation <= sessionCatalogGeneration) return; + sessionCatalogGeneration = generation; + latestSessionEventTsByKey.clear(); + successfulSessionListTsFloor = null; + pendingGenerationSessionEvents = { generation, events: [] }; } function getSessionsChangedExactKey(payload: GatewaySessionsChangedPayload): string | null { @@ -211,11 +95,8 @@ function getSessionEventUncertainty(events: GatewaySessionsChangedPayload[]): { for (const event of events) { if (typeof event.ts === 'number' && Number.isFinite(event.ts)) continue; const eventKey = getSessionsChangedExactKey(event); - if (eventKey) { - keys.add(eventKey); - } else { - unscoped = true; - } + if (eventKey) keys.add(eventKey); + else unscoped = true; } return { keys, unscoped }; } @@ -248,6 +129,44 @@ function hasExplicitSessionLabel(session: ChatSession | undefined): boolean { && !isSyntheticSessionFallbackLabel(session, session.label); } +function cleanSessionLabelText(text: string): string { + return text + .replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') + .replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '') + .replace(/^Sender\s*\([^)]*\)\s*:\s*[^\n]*(?:\n\s*)*/i, '') + .replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') + .replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '') + .replace(/^Sender\s*:\s*[^\n]*(?:\n\s*)*/i, '') + .replace(/^```json\n[\s\S]*?```\s*/i, '') + .replace(/^\{[\s\S]*?\}\s*/i, '') + .replace(/\s*\[media attached:[^\]]*\]/g, '') + .replace(/\s*\[message_id:\s*[^\]]+\]/g, '') + .replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '') + .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '') + .replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '') + .trim(); +} + +function toSessionLabel(text: string, maxLength = 50): string { + const cleaned = cleanSessionLabelText(text).trim(); + if (!cleaned) return ''; + return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength)}…` : cleaned; +} + +function toAutomaticSessionLabel(text: string, maxLength = 50): string { + if (isOpenClawHeartbeatPollText(text) || isAcpWorkingDirectoryTruncatedTitle(text)) return ''; + const textAfterInitialPrefix = stripAcpWorkingDirectoryPrefix(text); + const cleaned = cleanSessionLabelText(textAfterInitialPrefix); + const stripCwdExposedByCleanup = !textAfterInitialPrefix.startsWith('[Working directory: ') + && cleaned.startsWith('[Working directory: '); + const normalized = stripCwdExposedByCleanup + ? stripAcpWorkingDirectoryPrefix(cleaned) + : cleaned; + const title = normalized.trim(); + if (!title || isOpenClawHeartbeatPollText(title)) return ''; + return title.length > maxLength ? `${title.slice(0, maxLength)}…` : title; +} + function applySessionBackendLabels(set: ChatSet, sessions: ChatSession[]): void { set((state) => { let nextLabels = state.sessionLabels; @@ -301,7 +220,7 @@ function isHeartbeatOnlySummaryForSession( function getHeartbeatCachedLabelCleanup( sessions: ChatSession[], sessionLabels: Record, -): { hiddenSessionKeys: Set; staleLabelKeys: Set; labelKeys: Set } { +): { hiddenSessionKeys: Set; labelKeys: Set } { const hiddenSessionKeys = new Set(); const staleLabelKeys = new Set(); @@ -311,16 +230,12 @@ function getHeartbeatCachedLabelCleanup( ...session, lastMessagePreview: OPENCLAW_HEARTBEAT_POLL_SENTINEL, }; - if (isOpenClawHeartbeatOnlySession(heartbeatMarkedSession)) { - hiddenSessionKeys.add(session.key); - } else { - staleLabelKeys.add(session.key); - } + if (isOpenClawHeartbeatOnlySession(heartbeatMarkedSession)) hiddenSessionKeys.add(session.key); + else staleLabelKeys.add(session.key); } return { hiddenSessionKeys, - staleLabelKeys, labelKeys: new Set([...hiddenSessionKeys, ...staleLabelKeys]), }; } @@ -345,1757 +260,21 @@ async function fetchSessionLabelSummaries(sessionKeys: string[]): Promise, -): void { - if (summaries.length === 0) return; - set((state) => { - let nextLabels = state.sessionLabels; - let nextActivity = state.sessionLastActivity; - let nextSessions = state.sessions; - let switchPatch: Partial | null = null; - let changed = false; - - for (const summary of summaries) { - const session = nextSessions.find((entry) => entry.key === summary.sessionKey); - if (requestVersions) { - const requestVersion = requestVersions.get(summary.sessionKey); - if ( - !requestVersion - || !session - || !isSessionLabelHydrationVersionCurrent(summary.sessionKey, requestVersion) - || getSessionLabelHydrationVersion(session, nextActivity) !== requestVersion - ) { - continue; - } - } - if (isHeartbeatOnlySummaryForSession(session, summary)) { - if (nextSessions === state.sessions) { - nextSessions = [...state.sessions]; - } - nextSessions = nextSessions.filter((entry) => entry.key !== summary.sessionKey); - if (nextLabels === state.sessionLabels) { - nextLabels = { ...state.sessionLabels }; - } - delete nextLabels[summary.sessionKey]; - if (nextActivity === state.sessionLastActivity) { - nextActivity = { ...state.sessionLastActivity }; - } - delete nextActivity[summary.sessionKey]; - clearCachedSessionHistory(summary.sessionKey); - clearCachedSessionRunState(summary.sessionKey); - changed = true; - - if (state.currentSessionKey === summary.sessionKey) { - clearHistoryPoll(); - const prefix = getCanonicalPrefixFromSessionKey(summary.sessionKey) ?? DEFAULT_CANONICAL_PREFIX; - const replacementKey = `${prefix}:session-${Date.now()}`; - switchPatch = buildSessionSwitchPatch( - { - ...state, - sessions: nextSessions, - sessionLabels: nextLabels, - sessionLastActivity: nextActivity, - }, - replacementKey, - { createdLocally: true }, - ); - clearCachedSessionHistory(summary.sessionKey); - clearCachedSessionRunState(summary.sessionKey); - if (switchPatch.sessions) nextSessions = switchPatch.sessions; - if (switchPatch.sessionLabels) nextLabels = switchPatch.sessionLabels; - if (switchPatch.sessionLastActivity) nextActivity = switchPatch.sessionLastActivity; - } - continue; - } - - const labelText = toAutomaticSessionLabel(summary.firstUserText || ''); - // Only auto-hydrate missing labels. Existing entries include user renames - // and must not be overwritten by transcript-derived titles. - const existingLabel = nextLabels[summary.sessionKey]?.trim(); - const existingLabelIsFallback = isSyntheticSessionFallbackLabel(session, existingLabel); - if (labelText && (!existingLabel || existingLabelIsFallback) && !hasExplicitSessionLabel(session)) { - if (nextLabels === state.sessionLabels) { - nextLabels = { ...state.sessionLabels }; - } - nextLabels[summary.sessionKey] = labelText; - changed = true; - } - - if (typeof summary.lastTimestamp === 'number' && Number.isFinite(summary.lastTimestamp)) { - if (nextActivity[summary.sessionKey] !== summary.lastTimestamp) { - if (nextActivity === state.sessionLastActivity) { - nextActivity = { ...state.sessionLastActivity }; - } - nextActivity[summary.sessionKey] = summary.lastTimestamp; - changed = true; - } - } - - const workspacePath = typeof summary.workspacePath === 'string' - ? summary.workspacePath.trim() - : ''; - if (workspacePath) { - const sessionIndex = nextSessions.findIndex((session) => session.key === summary.sessionKey); - const existingSession = sessionIndex >= 0 ? nextSessions[sessionIndex] : undefined; - if (existingSession && existingSession.workspacePath !== workspacePath) { - if (nextSessions === state.sessions) { - nextSessions = [...state.sessions]; - } - nextSessions[sessionIndex] = { ...existingSession, workspacePath }; - changed = true; - } - } - } - - if (!changed) return {}; - - const patch: Partial = switchPatch ? { ...switchPatch } : {}; - if (nextLabels !== state.sessionLabels) patch.sessionLabels = nextLabels; - if (nextActivity !== state.sessionLastActivity) patch.sessionLastActivity = nextActivity; - if (nextSessions !== state.sessions) patch.sessions = nextSessions; - return patch; - }); -} - -async function refreshVisibleSessionSummaries( - set: ChatSet, - get: ChatGet, - sessionKeys?: string[], -): Promise { - const sessions = get().sessions; - const targetKeys = (sessionKeys && sessionKeys.length > 0 - ? sessionKeys - : sessions.map((session) => session.key) - ).filter((key) => key && key.startsWith('agent:')); - if (targetKeys.length === 0) return; - const sessionLastActivity = get().sessionLastActivity; - const requestVersions = new Map( - sessions - .filter((session) => targetKeys.includes(session.key)) - .map((session) => [ - session.key, - getSessionLabelHydrationVersion(session, sessionLastActivity), - ] as const), - ); - - try { - const summaries = await fetchSessionLabelSummaries(targetKeys); - applySessionLabelSummaries(set, summaries, requestVersions); - } catch (error) { - console.warn('[session summaries] refresh failed:', error); - } -} - -function cleanSessionLabelText(text: string): string { - return text - .replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*[^\n]*(?:\n\s*)*/i, '') - .replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^Sender\s*:\s*[^\n]*(?:\n\s*)*/i, '') - .replace(/^```json\n[\s\S]*?```\s*/i, '') - .replace(/^\{[\s\S]*?\}\s*/i, '') - .replace(/\s*\[media attached:[^\]]*\]/g, '') - .replace(/\s*\[message_id:\s*[^\]]+\]/g, '') - .replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '') - .trim(); -} - -function toSessionLabel(text: string, maxLength = 50): string { - const cleaned = cleanSessionLabelText(text).trim(); - if (!cleaned) return ''; - return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength)}…` : cleaned; -} - -function toAutomaticSessionLabel(text: string, maxLength = 50): string { - if (isOpenClawHeartbeatPollText(text)) return ''; - if (isAcpWorkingDirectoryTruncatedTitle(text)) return ''; - const textAfterInitialPrefix = stripAcpWorkingDirectoryPrefix(text); - const cleaned = cleanSessionLabelText(textAfterInitialPrefix); - const stripCwdExposedByCleanup = !textAfterInitialPrefix.startsWith('[Working directory: ') - && cleaned.startsWith('[Working directory: '); - const normalized = stripCwdExposedByCleanup - ? stripAcpWorkingDirectoryPrefix(cleaned) - : cleaned; - const title = normalized.trim(); - if (isOpenClawHeartbeatPollText(title)) return ''; - if (!title) return ''; - return title.length > maxLength ? `${title.slice(0, maxLength)}…` : title; -} - -async function withTimeout(promise: Promise, timeoutMs: number): Promise { - let timer: ReturnType | null = null; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } -} - -async function loadLocalHistoryFallback( - sessionKey: string, - limit = 200, - options: { timeoutMs?: number; logTimeout?: boolean } = {}, -): Promise { - const fallbackPromise = isCronSessionKey(sessionKey) - ? loadCronFallbackMessages(sessionKey, limit) - : loadSessionTranscriptFallback(sessionKey, limit); - const timeoutMs = options.timeoutMs ?? CHAT_HISTORY_DISK_FALLBACK_TIMEOUT_MS; - if (timeoutMs <= 0) { - return []; - } - return withTimeout(fallbackPromise, timeoutMs).catch((error) => { - if (options.logTimeout !== false) { - console.warn('[chat.history] local fallback timed out:', error); - } - return []; - }); -} - -function clearErrorRecoveryTimer(): void { - if (_errorRecoveryTimer) { - clearTimeout(_errorRecoveryTimer); - _errorRecoveryTimer = null; - } -} - -function isRecoverableRuntimeError(errorMessage: string): boolean { - const normalized = errorMessage.trim().toLowerCase(); - if (!normalized) return false; - return /\bterminated\b/.test(normalized) - || /\baborted\b/.test(normalized) - || normalized.includes('econnreset') - || normalized.includes('connection reset'); -} - -function scheduleRecoverableRuntimeError(commit: () => void): void { - clearErrorRecoveryTimer(); - _errorRecoveryTimer = setTimeout(() => { - _errorRecoveryTimer = null; - commit(); - }, ERROR_RECOVERY_DELAY_MS); -} - -function clearHistoryPoll(): void { - if (_historyPollTimer) { - clearTimeout(_historyPollTimer); - _historyPollTimer = null; - } -} - -function forceNextHistoryLoad(sessionKey: string): void { - _forceNextHistoryLoadBySession.add(sessionKey); -} - -function cloneHistoryMessages(messages: RawMessage[]): RawMessage[] { - return messages.map((message) => ({ - ...message, - _attachedFiles: message._attachedFiles?.map((file) => ({ ...file })), - })); -} - -function cacheSessionHistory(sessionKey: string, messages: RawMessage[], thinkingLevel: string | null): void { - _sessionHistoryCache.set(sessionKey, { - messages: cloneHistoryMessages(messages), - thinkingLevel, - }); -} - -function getCachedSessionHistory(sessionKey: string): { messages: RawMessage[]; thinkingLevel: string | null } | null { - const cached = _sessionHistoryCache.get(sessionKey); - if (!cached) return null; - return { - messages: cloneHistoryMessages(cached.messages), - thinkingLevel: cached.thinkingLevel, - }; -} - -function clearCachedSessionHistory(sessionKey: string): void { - _sessionHistoryCache.delete(sessionKey); -} - -function captureSessionRunState(sessionKey: string, state: SessionRunState): void { - _sessionRunStateCache.set(sessionKey, { - sending: state.sending, - activeRunId: state.activeRunId, - pendingFinal: state.pendingFinal, - lastUserMessageAt: state.lastUserMessageAt, - streamingText: state.streamingText, - streamingMessage: state.streamingMessage, - streamingTools: [...state.streamingTools], - pendingToolImages: state.pendingToolImages.map((file) => ({ ...file })), - }); -} - -function getCachedSessionRunState(sessionKey: string): SessionRunState { - const cached = _sessionRunStateCache.get(sessionKey); - if (!cached) return DEFAULT_SESSION_RUN_STATE; - return { - sending: cached.sending, - activeRunId: cached.activeRunId, - pendingFinal: cached.pendingFinal, - lastUserMessageAt: cached.lastUserMessageAt, - streamingText: cached.streamingText, - streamingMessage: cached.streamingMessage, - streamingTools: [...cached.streamingTools], - pendingToolImages: cached.pendingToolImages.map((file) => ({ ...file })), - }; -} - -function clearCachedSessionRunState(sessionKey: string): void { - _sessionRunStateCache.delete(sessionKey); -} - -function cloneSessionRunState(state: SessionRunState): SessionRunState { - return { - sending: state.sending, - activeRunId: state.activeRunId, - pendingFinal: state.pendingFinal, - lastUserMessageAt: state.lastUserMessageAt, - streamingText: state.streamingText, - streamingMessage: state.streamingMessage, - streamingTools: [...state.streamingTools], - pendingToolImages: state.pendingToolImages.map((file) => ({ ...file })), - }; -} - -function updateCachedSessionRunStateFromRuntimeEvent(event: ChatRuntimeEvent): void { - const sessionKey = event.sessionKey; - if (!sessionKey) return; - const cached = _sessionRunStateCache.get(sessionKey); - if (!cached) return; - - const next = cloneSessionRunState(cached); - const matchesCachedRun = next.activeRunId != null && event.runId === next.activeRunId; - const isCurrentUntrackedSend = next.activeRunId == null - && next.sending - && ( - typeof event.ts !== 'number' - || next.lastUserMessageAt == null - || event.ts >= next.lastUserMessageAt - 1_000 - ); - - if (event.type === 'run.started') { - if (next.activeRunId == null || matchesCachedRun) { - next.activeRunId = event.runId; - next.sending = true; - } - _sessionRunStateCache.set(sessionKey, next); - return; - } - - if (event.type === 'run.ended' && (matchesCachedRun || isCurrentUntrackedSend)) { - _sessionRunStateCache.set(sessionKey, DEFAULT_SESSION_RUN_STATE); - } -} - -function getHistoryForegroundLoadKey(sessionKey: string): string { - const gatewayState = useGatewayStore.getState?.() as { status?: { pid?: number; connectedAt?: number; port?: number } } | undefined; - const gatewayStatus = gatewayState?.status; - const gatewayRuntimeKey = `${gatewayStatus?.pid ?? 'none'}:${gatewayStatus?.connectedAt ?? 'none'}:${gatewayStatus?.port ?? 'none'}`; - return `${gatewayRuntimeKey}|${sessionKey}`; -} - -function pruneChatEventDedupe(now: number): void { - for (const [key, ts] of _chatEventDedupe.entries()) { - if (now - ts > CHAT_EVENT_DEDUPE_TTL_MS) { - _chatEventDedupe.delete(key); - } - } -} - -function buildChatEventDedupeKey(eventState: string, event: Record): string | null { - const runId = event.runId != null ? String(event.runId) : ''; - const sessionKey = event.sessionKey != null ? String(event.sessionKey) : ''; - const seq = event.seq != null ? String(event.seq) : ''; - // Some gateways emit multiple `delta` updates without a monotonically - // increasing `seq`. Deduping those by just `runId + sessionKey + state` - // collapses legitimate stream progression, so only seq-backed deltas are - // safe to dedupe generically. - if (eventState === 'delta' && !seq) { - return null; - } - if (runId || sessionKey || seq || eventState) { - return [runId, sessionKey, seq, eventState].join('|'); - } - const msg = (event.message && typeof event.message === 'object') - ? event.message as Record - : null; - if (msg) { - const messageId = msg.id != null ? String(msg.id) : ''; - const stopReason = msg.stopReason ?? msg.stop_reason; - if (messageId || stopReason) { - return `msg|${messageId}|${String(stopReason ?? '')}|${eventState}`; - } - } - return null; -} - -function getFinalMessageIdDedupeKey(eventState: string, event: Record): string | null { - if (eventState !== 'final') return null; - const msg = (event.message && typeof event.message === 'object') - ? event.message as Record - : null; - if (msg?.id != null) return `final-msgid|${String(msg.id)}`; - return null; -} - -function isDuplicateChatEvent(eventState: string, event: Record): boolean { - const key = buildChatEventDedupeKey(eventState, event); - const msgKey = getFinalMessageIdDedupeKey(eventState, event); - if (!key && !msgKey) return false; - const now = Date.now(); - pruneChatEventDedupe(now); - if ((key && _chatEventDedupe.has(key)) || (msgKey && _chatEventDedupe.has(msgKey))) { - return true; - } - if (key) _chatEventDedupe.set(key, now); - if (msgKey) _chatEventDedupe.set(msgKey, now); - return false; -} - -// ── Local image cache ───────────────────────────────────────── -// The Gateway doesn't store image attachments in session content blocks, -// so we cache them locally keyed by staged file path (which appears in the -// [media attached: ...] reference in the Gateway's user message text). -// Keying by path avoids the race condition of keying by runId (which is only -// available after the RPC returns, but history may load before that). -const IMAGE_CACHE_KEY = 'clawx:image-cache'; -const IMAGE_CACHE_MAX = 100; // max entries to prevent unbounded growth - -function loadImageCache(): Map { - try { - const raw = localStorage.getItem(IMAGE_CACHE_KEY); - if (raw) { - const entries = JSON.parse(raw) as Array<[string, AttachedFileMeta]>; - return new Map(entries); - } - } catch { /* ignore parse errors */ } - return new Map(); -} - -function saveImageCache(cache: Map): void { - try { - // Evict oldest entries if over limit - const entries = Array.from(cache.entries()); - const trimmed = entries.length > IMAGE_CACHE_MAX - ? entries.slice(entries.length - IMAGE_CACHE_MAX) - : entries; - localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(trimmed)); - } catch { /* ignore quota errors */ } -} - -const _imageCache = loadImageCache(); - -function normalizeBlockText(text: string | undefined): string { - return typeof text === 'string' ? text.replace(/\r\n/g, '\n').trim() : ''; -} - -function compactProgressiveTextParts(parts: string[]): string[] { - const compacted: string[] = []; - - for (const part of parts) { - const current = normalizeBlockText(part); - if (!current) continue; - - const previous = compacted.at(-1); - if (!previous) { - compacted.push(part); - continue; - } - - const normalizedPrevious = normalizeBlockText(previous); - if (!normalizedPrevious) { - compacted[compacted.length - 1] = part; - continue; - } - - if (current === normalizedPrevious || normalizedPrevious.startsWith(current)) { - continue; - } - - if (current.startsWith(normalizedPrevious)) { - compacted[compacted.length - 1] = part; - continue; - } - - compacted.push(part); - } - - return compacted; -} - -function normalizeLiveContentBlocks(content: ContentBlock[]): ContentBlock[] { - return content.map((block) => ({ ...block })); -} - -function normalizeStreamingMessage(message: unknown): unknown { - if (!message || typeof message !== 'object') return message; - - const rawMessage = message as RawMessage; - const rawContent = rawMessage.content; - if (!Array.isArray(rawContent)) return rawMessage; - - const normalizedContent = normalizeLiveContentBlocks(rawContent as ContentBlock[]); - const didChange = normalizedContent.some((block, index) => block !== rawContent[index]) - || normalizedContent.length !== rawContent.length; - - return didChange - ? { ...rawMessage, content: normalizedContent } - : rawMessage; -} - -/** - * Strip Gateway-injected metadata that does NOT exist on the renderer's - * optimistic user message but is echoed back when the Gateway persists it: - * - leading sender metadata `Sender (untrusted metadata): ...` - * - leading timestamp `[Wed 2026-04-22 10:30 GMT+8] ` - * - `[message_id: uuid]` tags sprinkled throughout the text - * - `[media attached: path (mime) | path]` references appended when the - * renderer sends attachments via `chat:sendWithMedia` - * - Gateway-injected "Conversation info (untrusted metadata): ..." blocks - * - * Keeping this aligned with `cleanUserText` in `pages/Chat/message-utils.ts` - * is important: the user bubble renders the cleaned text, so the comparison - * used to dedupe optimistic vs server echoes must operate on the same - * cleaned form — otherwise the same visible message renders twice. - * - * Order matters: the `[media attached: ...]` lines are commonly emitted - * BETWEEN the Sender block and the `[Mon ... GMT+8]` timestamp prefix. - * If we strip the timestamp before the media-attached lines, the timestamp - * regex (`^\s*\[(?:Mon|...)]`) can never match because the leading `[` is - * `[media attached:` instead — leaving the timestamp in the normalized - * comparison text and breaking optimistic-vs-echo dedupe. - */ -function stripInboundMediaVisionEnvelope(text: string): string { - if (!/\[Image\]/i.test(text) && !/^User text:/im.test(text) && !/\nDescription:\s*\n/i.test(text)) { - return text; - } - - let result = text.replace(/^\s*\[Image\]\s*\n?/i, ''); - - const userTextBlock = result.match(/^User text:\s*\n([\s\S]*?)(?:\n\s*Description:\s*\n[\s\S]*)?\s*$/i); - if (userTextBlock) { - const userText = userTextBlock[1].trim(); - return /^Process the attached file\(s\)\.\s*$/i.test(userText) ? '' : userText; - } - - return result.replace(/\n\s*Description:\s*\n[\s\S]*$/i, '').trim(); -} - -function stripGatewayUserMetadata(text: string): string { - return stripInboundMediaVisionEnvelope( - text - .replace(/\s*\[media attached:[^\]]*\]/g, '') - .replace(/\s*\[message_id:\s*[^\]]+\]/g, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*[^\n]*(?:\n\s*)*/i, '') - .replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^Sender\s*:\s*[^\n]*(?:\n\s*)*/i, '') - .replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, ''), - ); -} - -function normalizeComparableUserText(content: unknown): string { - const text = stripGatewayUserMetadata(getMessageText(content)) - .replace(/\s+/g, ' ') - .trim(); - if (/^\(file attached\)$/i.test(text)) return ''; - return text; -} - -function getComparableAttachmentSignature(message: Pick): string { - const files = (message._attachedFiles || []) - .map((file) => file.filePath || `${file.fileName}|${file.mimeType}|${file.fileSize}`) - .filter(Boolean) - .sort(); - return files.join('::'); -} - -function matchesOptimisticUserMessage( - candidate: RawMessage, - optimistic: RawMessage, - optimisticTimestampMs: number, -): boolean { - if (candidate.role !== 'user') return false; - - const optimisticText = normalizeComparableUserText(optimistic.content); - const candidateText = normalizeComparableUserText(candidate.content); - const sameText = optimisticText.length > 0 && optimisticText === candidateText; - - const optimisticAttachments = getComparableAttachmentSignature(optimistic); - const candidateAttachments = getComparableAttachmentSignature(candidate); - const sameAttachments = optimisticAttachments.length > 0 && optimisticAttachments === candidateAttachments; - - const hasOptimisticTimestamp = Number.isFinite(optimisticTimestampMs) && optimisticTimestampMs > 0; - const hasCandidateTimestamp = candidate.timestamp != null; - const timestampMatches = hasOptimisticTimestamp && hasCandidateTimestamp - ? Math.abs(toMs(candidate.timestamp as number) - optimisticTimestampMs) < OPTIMISTIC_USER_TIMESTAMP_MATCH_MS - : false; - - if (sameText && sameAttachments) return true; - if (sameText && (!optimisticAttachments || !candidateAttachments) && (timestampMatches || !hasCandidateTimestamp)) return true; - if (sameAttachments && (!optimisticText || !candidateText) && (timestampMatches || !hasCandidateTimestamp)) return true; - - const optimisticHadAttachmentsOnly = optimisticAttachments.length > 0 && !optimisticText; - const candidateIsAttachmentEcho = !candidateText - && /\[(?:media attached:|\s*Image\s*\])/i.test(getMessageText(candidate.content)); - if (optimisticHadAttachmentsOnly && candidateIsAttachmentEcho && (timestampMatches || !hasCandidateTimestamp)) { - return true; - } - return false; -} - -function rememberPendingOptimisticUserMessage(sessionKey: string, message: RawMessage, timestampMs: number): void { - const now = Date.now(); - const existing = (_pendingOptimisticUserMessages.get(sessionKey) || []) - .filter((entry) => now - entry.createdAtMs <= OPTIMISTIC_USER_MESSAGE_TTL_MS); - existing.push({ message, timestampMs, createdAtMs: now }); - _pendingOptimisticUserMessages.set(sessionKey, existing); -} - -function clearPendingOptimisticUserMessages(sessionKey: string): void { - _pendingOptimisticUserMessages.delete(sessionKey); -} - -function mergePendingOptimisticUserMessages(sessionKey: string, loadedMessages: RawMessage[]): RawMessage[] { - const pending = _pendingOptimisticUserMessages.get(sessionKey); - if (!pending || pending.length === 0) return loadedMessages; - - const now = Date.now(); - let merged = loadedMessages; - const stillPending: PendingOptimisticUserMessage[] = []; - - for (const entry of pending) { - if (now - entry.createdAtMs > OPTIMISTIC_USER_MESSAGE_TTL_MS) { - continue; - } - - const hasServerEcho = hasOptimisticServerEcho(loadedMessages, entry.message, entry.timestampMs); - if (hasServerEcho) { - continue; - } - - const alreadyRendered = merged.some((message) => - message.id === entry.message.id || matchesOptimisticUserMessage(message, entry.message, entry.timestampMs), - ); - if (!alreadyRendered) { - const insertAt = merged.findIndex((message) => - typeof message.timestamp === 'number' && toMs(message.timestamp) > entry.timestampMs, - ); - merged = insertAt === -1 - ? [...merged, entry.message] - : [...merged.slice(0, insertAt), entry.message, ...merged.slice(insertAt)]; - } - - stillPending.push(entry); - } - - if (stillPending.length > 0) { - _pendingOptimisticUserMessages.set(sessionKey, stillPending); - } else { - _pendingOptimisticUserMessages.delete(sessionKey); - } - - return merged; -} - -function snapshotStreamingAssistantMessage( - currentStream: RawMessage | null, - existingMessages: RawMessage[], - runId: string, -): RawMessage[] { - if (!currentStream) return []; - - const normalizedStream = normalizeStreamingMessage(currentStream) as RawMessage; - const streamRole = normalizedStream.role; - if (streamRole !== 'assistant' && streamRole !== undefined) return []; - - const snapId = normalizedStream.id || `${runId || 'run'}-turn-${existingMessages.length}`; - if (existingMessages.some((message) => message.id === snapId)) return []; - - return [{ - ...normalizedStream, - role: 'assistant', - id: snapId, - }]; -} - -function getLatestOptimisticUserMessage(messages: RawMessage[], userTimestampMs: number): RawMessage | undefined { - return [...messages].reverse().find( - (message) => message.role === 'user' - && (!message.timestamp || Math.abs(toMs(message.timestamp) - userTimestampMs) < OPTIMISTIC_USER_TIMESTAMP_MATCH_MS), - ); -} - -function hasOptimisticServerEcho( - loadedMessages: RawMessage[], - optimistic: RawMessage, - optimisticTimestampMs: number, -): boolean { - if (loadedMessages.some((message) => - matchesOptimisticUserMessage(message, optimistic, optimisticTimestampMs), - )) { - return true; - } - - const optimisticText = normalizeComparableUserText(optimistic.content); - if (!optimisticText) return false; - - const matchingUsers = loadedMessages.filter( - (message) => message.role === 'user' - && normalizeComparableUserText(message.content) === optimisticText, - ); - if (matchingUsers.length !== 1) return false; - - const candidate = matchingUsers[0]!; - if (candidate.timestamp == null) return true; - - return Math.abs(toMs(candidate.timestamp as number) - optimisticTimestampMs) < OPTIMISTIC_USER_TIMESTAMP_MATCH_MS; -} - -function dropRedundantOptimisticUserMessages(sessionKey: string, messages: RawMessage[]): RawMessage[] { - const pending = _pendingOptimisticUserMessages.get(sessionKey); - if (!pending?.length) return messages; - - const pendingIds = new Set( - pending - .map((entry) => entry.message.id) - .filter((id): id is string => typeof id === 'string' && id.length > 0), - ); - if (pendingIds.size === 0) return messages; - - return messages.filter((message) => { - if (message.role !== 'user' || !message.id || !pendingIds.has(message.id)) { - return true; - } - const entry = pending.find((candidate) => candidate.message.id === message.id); - if (!entry) return true; - return !hasOptimisticServerEcho( - messages.filter((candidate) => candidate !== message), - entry.message, - entry.timestampMs, - ); - }); -} - -/** Extract plain text from message content (string or content blocks) */ -function getMessageText(content: unknown): string { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - const parts = (content as Array<{ type?: string; text?: string }>) - .filter(b => b.type === 'text' && b.text) - .map(b => b.text!); - return compactProgressiveTextParts(parts).join('\n'); - } - return ''; -} - -function getMessageTextForFilter(msg: { content?: unknown; text?: unknown }): string { - const fromContent = getMessageText(msg.content); - if (fromContent.trim()) return fromContent; - if (typeof msg.text === 'string') return msg.text; - return ''; -} - -function getMessageStopReason(message: RawMessage | unknown): string | null { - if (!message || typeof message !== 'object') return null; - const msg = message as Record; - const rawStopReason = msg.stopReason ?? msg.stop_reason; - if (typeof rawStopReason !== 'string') return null; - const normalized = rawStopReason.trim().toLowerCase(); - return normalized || null; -} - -function getMessageErrorMessage(message: RawMessage | unknown): string | null { - if (!message || typeof message !== 'object') return null; - const msg = message as Record; - const rawError = msg.errorMessage ?? msg.error_message; - if (typeof rawError !== 'string') return null; - const normalized = rawError.trim(); - return normalized || null; -} - -function isTerminalAssistantErrorMessage(message: RawMessage | unknown): boolean { - if (!message || typeof message !== 'object') return false; - const msg = message as Record; - return msg.role === 'assistant' && getMessageStopReason(message) === 'error'; -} - -function shouldShowRunError( - sessionKey: string, - errorMessage: string | null | undefined, - dismissedBySession: Record, -): string | null { - if (!errorMessage) return null; - if (dismissedBySession[sessionKey] === errorMessage) return null; - return errorMessage; -} - -function withoutDismissedRunError( - dismissedBySession: Record, - sessionKey: string, -): Record { - if (!(sessionKey in dismissedBySession)) return dismissedBySession; - const next = { ...dismissedBySession }; - delete next[sessionKey]; - return next; -} - -/** Extract media file refs from [media attached: () | ...] patterns */ -function extractMediaRefs(text: string): Array<{ filePath: string; mimeType: string }> { - const refs: Array<{ filePath: string; mimeType: string }> = []; - const regex = /\[media attached:\s*([^\s(]+)\s*\(([^)]+)\)\s*\|[^\]]*\]/g; - let match; - while ((match = regex.exec(text)) !== null) { - refs.push({ filePath: match[1], mimeType: match[2] }); - } - return refs; -} - -/** Map common file extensions to MIME types */ -function mimeFromExtension(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - // Images - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'webp': 'image/webp', - 'bmp': 'image/bmp', - 'avif': 'image/avif', - 'svg': 'image/svg+xml', - // Documents - 'pdf': 'application/pdf', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'xls': 'application/vnd.ms-excel', - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', - 'md': 'text/markdown', - 'rtf': 'application/rtf', - 'epub': 'application/epub+zip', - // Archives - 'zip': 'application/zip', - 'tar': 'application/x-tar', - 'gz': 'application/gzip', - 'rar': 'application/vnd.rar', - '7z': 'application/x-7z-compressed', - // Audio - 'mp3': 'audio/mpeg', - 'wav': 'audio/wav', - 'ogg': 'audio/ogg', - 'aac': 'audio/aac', - 'flac': 'audio/flac', - 'm4a': 'audio/mp4', - // Video - 'mp4': 'video/mp4', - 'mov': 'video/quicktime', - 'avi': 'video/x-msvideo', - 'mkv': 'video/x-matroska', - 'webm': 'video/webm', - 'm4v': 'video/mp4', - }; - return map[ext] || 'application/octet-stream'; -} - -function extractFilePathsFromToolArgs(args: Record): string[] { - const paths: string[] = []; - const direct = args.file_path ?? args.filePath ?? args.path ?? args.file ?? args.media ?? args.mediaUrl; - if (typeof direct === 'string' && direct.trim()) paths.push(direct.trim()); - const mediaUrls = args.mediaUrls; - if (Array.isArray(mediaUrls)) { - for (const value of mediaUrls) { - if (typeof value === 'string' && value.trim()) paths.push(value.trim()); - } - } - - const attachments = args.attachments; - if (Array.isArray(attachments)) { - for (const item of attachments) { - if (!item || typeof item !== 'object') continue; - const att = item as Record; - const filePath = att.filePath ?? att.file_path ?? att.path ?? att.file; - if (typeof filePath === 'string' && filePath.trim()) { - paths.push(filePath.trim()); - } - } - } - - return paths; -} - -function isImagePathLike(value: string): boolean { - return /\.(?:png|jpe?g|gif|webp|bmp|avif|svg)(?:$|[?#])/i.test(value.trim()); -} - -function collectMediaValues(record: Record | null | undefined): string[] { - if (!record) return []; - const values: string[] = []; - const push = (value: unknown) => { - if (typeof value === 'string' && value.trim()) values.push(value.trim()); - }; - push(record.media); - push(record.mediaUrl); - push(record.filePath); - const mediaUrls = record.mediaUrls; - if (Array.isArray(mediaUrls)) { - for (const value of mediaUrls) push(value); - } - return values; -} - -function parseMessageToolResultJson(msg: RawMessage): Record | null { - const text = getMessageText(msg.content); - if (!text.trim()) return null; - try { - const parsed = JSON.parse(text); - return parsed && typeof parsed === 'object' ? parsed as Record : null; - } catch { - return null; - } -} - -type MessageToolDelivery = { - files: AttachedFileMeta[]; - text: string; - internalUi: boolean; -}; - -function collectMessageToolDelivery(msg: RawMessage): MessageToolDelivery | null { - if (!isToolResultRole(msg.role)) return null; - if (msg.toolName !== 'message') return null; - - const details = msg.details && typeof msg.details === 'object' - ? msg.details as Record - : parseMessageToolResultJson(msg); - if (!details) return null; - if (String(details.status ?? '').toLowerCase() === 'error') return null; - - const sourceReply = details.sourceReply && typeof details.sourceReply === 'object' - ? details.sourceReply as Record - : null; - const seen = new Set(); - const files: AttachedFileMeta[] = []; - - for (const media of [...collectMediaValues(details), ...collectMediaValues(sourceReply)]) { - if (seen.has(media)) continue; - seen.add(media); - if (media.startsWith('/api/chat/media/')) { - files.push({ - fileName: 'image', - mimeType: 'image/png', - fileSize: 0, - preview: null, - gatewayUrl: media, - source: 'gateway-media', - }); - continue; - } - if (!isImagePathLike(media)) continue; - files.push({ ...makeAttachedFile({ filePath: media, mimeType: mimeFromExtension(media) }), source: 'tool-result' }); - } - - const sourceReplyText = sourceReply?.text; - const detailsMessage = details.message; - return { - files, - text: typeof sourceReplyText === 'string' && sourceReplyText.trim() - ? sourceReplyText.trim() - : typeof detailsMessage === 'string' ? detailsMessage.trim() : '', - internalUi: details.sourceReplySink === 'internal-ui' - || details.sourceReplyDeliveryMode === 'message_tool_only', - }; -} - -function createInternalUiDeliveryReply(msg: RawMessage, delivery: MessageToolDelivery): RawMessage | null { - if (!delivery.internalUi || (!delivery.text && delivery.files.length === 0)) return null; - const idBase = msg.id || msg.toolCallId; - return { - role: 'assistant', - content: delivery.text ? [{ type: 'text', text: delivery.text }] : [], - timestamp: msg.timestamp, - ...(idBase ? { id: `${idBase}:source-reply` } : {}), - _attachedFiles: delivery.files, - }; -} - -const DIRECTORY_MIME_TYPE = 'application/x-directory'; - -function trimPathTerminators(filePath: string): string { - return filePath.replace(/[,。;;,.!?]+$/u, ''); -} - -/** - * Extract raw file paths from message text. - * Detects absolute paths (Unix: / or ~/, Windows: C:\ etc.) ending with common file extensions. - * Handles both image and non-image files, consistent with channel push message behavior. - * - * Also recognises the `MEDIA:` / `media:` prefix the OpenClaw runtime - * emits for produced artifacts (e.g. - * `MEDIA:/tmp/desktop_screenshot.png`, `MEDIA:C:\Users\me\out.svg`) — without this the leading colon - * trips the URL guard on the unix regex below and the artifact never - * surfaces as an attachment. Mirrors `chat/helpers.ts::extractRawFilePaths`. - */ -function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: string }> { - const refs: Array<{ filePath: string; mimeType: string }> = []; - const seen = new Set(); - const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v'; - // Tagged media references (MEDIA:/path, media:~/path, MEDIA:C:\path, ...). The agent - // runtime uses this prefix as an explicit "this is an artifact" marker, - // so we want them recognised even though the leading colon would - // normally look like a URL scheme. After matching we punch the entire - // `MEDIA:` span out of the working text so the generic unix - // regex below doesn't double-count the bare `/path` suffix. - // The character class deliberately allows ASCII spaces inside the path so - // that macOS' default screenshot filename ("截屏 2026-05-06 17.46.51.png") - // and other space-containing paths the agent emits with the explicit - // `MEDIA:` marker still resolve. Newline and quote characters remain - // path terminators so we don't accidentally swallow trailing prose. - const taggedRegex = new RegExp(`(?` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g'); - let workingText = text; - let taggedMatch: RegExpExecArray | null; - while ((taggedMatch = taggedRegex.exec(text)) !== null) { - const p = taggedMatch[1]; - if (p && !seen.has(p)) { - seen.add(p); - refs.push({ filePath: p, mimeType: mimeFromExtension(p) }); - } - // Mask the matched span so subsequent regexes can't re-discover the - // same path (e.g. `/two.xlsx` from `MEDIA:~/two.xlsx`). - const start = taggedMatch.index; - const end = start + taggedMatch[0].length; - workingText = workingText.slice(0, start) + ' '.repeat(end - start) + workingText.slice(end); - } - // Unix absolute paths (/... or ~/...) — lookbehind rejects mid-token slashes - // (e.g. "path/to/file.mp4", "https://example.com/file.mp4") - const unixRegex = new RegExp(`(?]*?\\.(?:${exts}))`, 'gi'); - // Windows absolute paths (C:\... D:\...) — lookbehind rejects drive letter glued to a word - const winRegex = new RegExp(`(?]*?\\.(?:${exts}))`, 'gi'); - const skillPathBoundary = '(?=$|\\s|[\\x5b\\x5d"\'`(),<>,。;;,.!?])'; - const skillPathPart = '[^\\\\/\\s\\n"\'`()\\x5b\\x5d,<>]+'; - const skillPathTail = '[^\\s\\n"\'`()\\x5b\\x5d,<>]*?'; - const skillDirRegex = new RegExp( - `(? | undefined; - if (args) { - const paths = extractFilePathsFromToolArgs(args); - if (paths[0]) return paths[0]; - } - } - } - } - - // OpenAI format — tool_calls array on the message itself - const msgAny = msg as unknown as Record; - const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; - if (Array.isArray(toolCalls)) { - for (const tc of toolCalls as Array>) { - if (tc.id !== toolCallId) continue; - const fn = (tc.function ?? tc) as Record; - let args: Record | undefined; - try { - args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : (fn.arguments ?? fn.input) as Record; - } catch { /* ignore */ } - if (args) { - const paths = extractFilePathsFromToolArgs(args); - if (paths[0]) return paths[0]; - } - } - } - - return undefined; -} - -/** - * Collect all tool call file paths from a message into a Map. - */ -function collectToolCallPaths(msg: RawMessage, paths: Map): void { - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id) { - const args = (block.input ?? block.arguments) as Record | undefined; - if (args) { - const filePaths = extractFilePathsFromToolArgs(args); - if (filePaths[0]) paths.set(block.id, filePaths[0]); - } - } - } - } - const msgAny = msg as unknown as Record; - const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; - if (Array.isArray(toolCalls)) { - for (const tc of toolCalls as Array>) { - const id = typeof tc.id === 'string' ? tc.id : ''; - if (!id) continue; - const fn = (tc.function ?? tc) as Record; - let args: Record | undefined; - try { - args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : (fn.arguments ?? fn.input) as Record; - } catch { /* ignore */ } - if (args) { - const filePaths = extractFilePathsFromToolArgs(args); - if (filePaths[0]) paths.set(id, filePaths[0]); - } - } - } -} - -function assistantHasToolCallId(msg: RawMessage, toolCallId: string): boolean { - if (!toolCallId) return false; - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id === toolCallId) { - return true; - } - } - } - const msgAny = msg as unknown as Record; - const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; - return Array.isArray(toolCalls) && toolCalls.some((tc) => - tc && typeof tc === 'object' && (tc as Record).id === toolCallId, - ); -} - -/** - * Before filtering tool_result messages from history, scan them for any file/image - * content and attach those to the immediately following assistant message. - * This mirrors channel push message behavior where tool outputs surface files to the UI. - * Handles: - * - Image content blocks (base64 / url) - * - [media attached: path (mime) | path] text patterns in tool result output - * - Raw file paths in tool result text - */ -function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] { - const pending: AttachedFileMeta[] = []; - const toolCallPaths = new Map(); - const enriched: RawMessage[] = []; - - for (const msg of messages) { - // Track file paths from assistant tool call arguments for later matching - if (msg.role === 'assistant') { - collectToolCallPaths(msg, toolCallPaths); - } - - if (isToolResultRole(msg.role)) { - const delivery = collectMessageToolDelivery(msg); - const deliveredFiles = delivery?.files ?? []; - const internalUiReply = delivery ? createInternalUiDeliveryReply(msg, delivery) : null; - if (internalUiReply) { - enriched.push(msg, internalUiReply); - continue; - } - if (deliveredFiles.length > 0) { - let attachIndex = -1; - if (msg.toolCallId) { - for (let index = enriched.length - 1; index >= 0; index -= 1) { - const candidate = enriched[index]; - if (candidate?.role !== 'assistant') continue; - if (assistantHasToolCallId(candidate, msg.toolCallId)) { - attachIndex = index; - break; - } - } - } - - if (attachIndex >= 0) { - const target = enriched[attachIndex]!; - const existingKeys = new Set( - (target._attachedFiles || []).map(file => file.filePath || file.gatewayUrl).filter(Boolean), - ); - const newFiles = deliveredFiles.filter(file => { - const key = file.filePath || file.gatewayUrl; - return !key || !existingKeys.has(key); - }); - if (newFiles.length > 0) { - enriched[attachIndex] = { - ...target, - _attachedFiles: [...(target._attachedFiles || []), ...newFiles], - }; - } - } else { - pending.push(...deliveredFiles); - } - } - - // Resolve file path from the matching tool call - const matchedPath = msg.toolCallId ? toolCallPaths.get(msg.toolCallId) : undefined; - - // 1. Image/file content blocks in the structured content array. - // Images embedded inside a tool result are the model's vision data - // (e.g. `read /tmp/foo.png` re-encoded as JPEG so the model can "see" - // the file) — they are NOT user-facing artifacts. The agent surfaces - // user-facing images through `MEDIA:/path` text + the Gateway's - // `assistant-media` injection. Surfacing the vision data here would - // duplicate every screenshot the agent inspects. - const imageFiles = extractImagesAsAttachedFiles(msg.content) - .filter(file => !file.mimeType.startsWith('image/')); - if (matchedPath) { - for (const f of imageFiles) { - if (!f.filePath) { - f.filePath = matchedPath; - f.fileName = matchedPath.split(/[\\/]/).pop() || 'image'; - } - } - } - // Tag all files from tool results so ChatMessage can suppress them - // in segments that already have an ExecutionGraphCard. - for (const f of imageFiles) f.source = 'tool-result'; - pending.push(...imageFiles); - - // 2. [media attached: ...] patterns in tool result text output - const text = getMessageText(msg.content); - if (text) { - const mediaRefs = extractMediaRefs(text); - const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); - for (const ref of mediaRefs) { - pending.push({ ...makeAttachedFile(ref), source: 'tool-result' }); - } - // 3. Raw NON-image file paths in tool result text (documents, - // audio, video, ...). Image paths are deliberately ignored: - // `ls -la /tmp/foo.png`, `sips ... && ls -la *.jpg`, etc. - // spam intermediate paths that the user does not want to see - // rendered as separate cards. The canonical user-facing image - // is whatever the agent later emits via `MEDIA:/path` (which - // the Gateway turns into a dedicated assistant-media bubble). - for (const ref of extractRawFilePaths(text)) { - if (mediaRefPaths.has(ref.filePath)) continue; - if (ref.mimeType.startsWith('image/')) continue; - pending.push({ ...makeAttachedFile(ref), source: 'tool-result' }); - } - } - - enriched.push(msg); // will be filtered later - continue; - } - - if (msg.role === 'assistant' && pending.length > 0) { - const toAttach = pending.splice(0); - // Deduplicate against files already on the assistant message - const existingKeys = new Set( - (msg._attachedFiles || []).map(f => f.filePath || f.gatewayUrl).filter(Boolean), - ); - const newFiles = toAttach.filter(f => { - const key = f.filePath || f.gatewayUrl; - return !key || !existingKeys.has(key); - }); - if (newFiles.length === 0) { - enriched.push(msg); - continue; - } - enriched.push({ - ...msg, - _attachedFiles: [...(msg._attachedFiles || []), ...newFiles], - }); - continue; - } - - enriched.push(msg); - } - - return enriched; -} - -/** - * Restore _attachedFiles for messages loaded from history. - * Handles: - * 1. [media attached: path (mime) | path] patterns (attachment-button flow) - * 2. Raw image file paths typed in message text (e.g. /Users/.../image.png) - * Uses local cache for previews when available; missing previews are loaded async. - */ -function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] { - // Pre-compute, per index, whether the *next* assistant message is a - // Gateway-injected `assistant-media` bubble (i.e. has at least one - // `image` content block carrying a flat URL). When that bubble exists, - // the canonical user-facing rendering of the artifact is the bubble - // itself — anything the agent emitted via `MEDIA:/path` in its prior - // text turn would just duplicate the same image, so image-typed raw - // refs on that prior message should be dropped here. - const nextHasGatewayMediaBubble = messages.map((_, idx) => { - const next = messages[idx + 1]; - if (!next || next.role !== 'assistant') return false; - return extractImagesAsAttachedFiles(next.content).some(f => f.gatewayUrl); - }); - - return messages.map((msg, idx) => { - // Only process user and assistant messages. - if (msg.role !== 'user' && msg.role !== 'assistant') return msg; - const text = getMessageText(msg.content); - - // Path 0: Gateway-injected outgoing media on this same message - // (an `assistant-media` bubble — image block with flat `url`). - const gatewayMediaFiles: AttachedFileMeta[] = msg.role === 'assistant' - ? extractImagesAsAttachedFiles(msg.content).filter(file => file.gatewayUrl) - : []; - - // Path 1: [media attached: path (mime) | path] — guaranteed format from attachment button - const mediaRefs = extractMediaRefs(text); - const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); - - // Path 2: Raw file paths. - // For assistant messages: scan own text AND the nearest preceding user message text, - // but only for non-tool-only assistant messages (i.e. the final answer turn). - let rawRefs: Array<{ filePath: string; mimeType: string }> = []; - if (msg.role === 'assistant' && !isToolOnlyMessage(msg)) { - // Own text - rawRefs = extractRawFilePaths(text).filter(r => !mediaRefPaths.has(r.filePath)); - - // Nearest preceding user message text (look back up to 5 messages) - const seenPaths = new Set(rawRefs.map(r => r.filePath)); - for (let i = idx - 1; i >= Math.max(0, idx - 5); i--) { - const prev = messages[i]; - if (!prev) break; - if (prev.role === 'user') { - const prevText = getMessageText(prev.content); - for (const ref of extractRawFilePaths(prevText)) { - if (!mediaRefPaths.has(ref.filePath) && !seenPaths.has(ref.filePath)) { - seenPaths.add(ref.filePath); - rawRefs.push(ref); - } - } - break; // only use the nearest user message - } - } - } - - // Dedup vs Gateway-injected bubble: if the very next assistant message - // is a Gateway `assistant-media` bubble, drop image-typed raw refs on - // *this* message — the bubble already covers them. - if (msg.role === 'assistant' && nextHasGatewayMediaBubble[idx]) { - rawRefs = rawRefs.filter(r => !r.mimeType.startsWith('image/')); - } - - const allRefs = [...mediaRefs, ...rawRefs]; - if (allRefs.length === 0 && gatewayMediaFiles.length === 0) { - // Preserve any previously-attached `_attachedFiles` (e.g. set by - // `enrichWithToolResultFiles` for non-image artifacts). When nothing - // new applies, returning `msg` unmodified keeps those attachments. - return msg; - } - - const existingFiles = msg._attachedFiles || []; - const existingPaths = new Set(existingFiles.map(file => file.filePath).filter(Boolean)); - const existingGatewayUrls = new Set( - existingFiles.map(file => file.gatewayUrl).filter(Boolean) as string[], - ); - const files: AttachedFileMeta[] = allRefs - .filter(ref => !existingPaths.has(ref.filePath)) - .map(ref => { - const cached = _imageCache.get(ref.filePath); - if (cached) return { ...cached, filePath: ref.filePath, source: 'message-ref' as const }; - const fileName = ref.filePath.split(/[\\/]/).pop() || 'file'; - return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath, source: 'message-ref' as const }; - }); - const dedupedGatewayMedia = gatewayMediaFiles.filter( - file => file.gatewayUrl && !existingGatewayUrls.has(file.gatewayUrl), - ); - if (files.length === 0 && dedupedGatewayMedia.length === 0) return msg; - return { ...msg, _attachedFiles: [...existingFiles, ...files, ...dedupedGatewayMedia] }; - }); -} - -type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string }; - -const IMAGE_PREVIEW_RETRY_DELAYS_MS = [300, 900, 1800]; - -function waitForPreviewRetry(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function collectMissingPreviewRefs(messages: RawMessage[]): PreviewRef[] { - const needPreview: PreviewRef[] = []; - const seenKeys = new Set(); - - for (const msg of messages) { - if (!msg._attachedFiles) continue; - - // Path 1: files with explicit filePath OR gatewayUrl - for (const file of msg._attachedFiles) { - const key = file.filePath || file.gatewayUrl; - if (!key || seenKeys.has(key)) continue; - const needsLoad = file.mimeType.startsWith('image/') - ? !file.preview && file.previewStatus !== 'unavailable' - : file.fileSize === 0; - if (!needsLoad) continue; - seenKeys.add(key); - if (file.filePath) { - needPreview.push({ filePath: file.filePath, mimeType: file.mimeType }); - } else if (file.gatewayUrl) { - needPreview.push({ gatewayUrl: file.gatewayUrl, mimeType: file.mimeType }); - } - } - - // Path 2: [media attached: ...] patterns (legacy — in case filePath wasn't stored) - if (msg.role === 'user') { - const text = getMessageText(msg.content); - const refs = extractMediaRefs(text); - for (let i = 0; i < refs.length; i++) { - const file = msg._attachedFiles[i]; - const ref = refs[i]; - if (!file || !ref || seenKeys.has(ref.filePath)) continue; - const needsLoad = ref.mimeType.startsWith('image/') - ? !file.preview && file.previewStatus !== 'unavailable' - : file.fileSize === 0; - if (needsLoad) { - seenKeys.add(ref.filePath); - needPreview.push({ filePath: ref.filePath, mimeType: ref.mimeType }); - } - } - } - } - - return needPreview; -} - -function applyPreviewResults( - messages: RawMessage[], - thumbnails: Record, -): boolean { - let updated = false; - for (const msg of messages) { - if (!msg._attachedFiles) continue; - - // Update files that have filePath OR gatewayUrl - for (const file of msg._attachedFiles) { - const key = file.filePath || file.gatewayUrl; - if (!key) continue; - const thumb = thumbnails[key]; - if (thumb && (thumb.preview || thumb.fileSize)) { - if (thumb.preview) file.preview = thumb.preview; - if (thumb.fileSize) file.fileSize = thumb.fileSize; - delete file.previewStatus; - if (file.filePath) { - _imageCache.set(file.filePath, { ...file }); - } - updated = true; - } - } - - // Legacy: update by index for [media attached: ...] refs - if (msg.role === 'user') { - const text = getMessageText(msg.content); - const refs = extractMediaRefs(text); - for (let i = 0; i < refs.length; i++) { - const file = msg._attachedFiles[i]; - const ref = refs[i]; - if (!file || !ref || file.filePath) continue; // skip if already handled via filePath - const thumb = thumbnails[ref.filePath]; - if (thumb && (thumb.preview || thumb.fileSize)) { - if (thumb.preview) file.preview = thumb.preview; - if (thumb.fileSize) file.fileSize = thumb.fileSize; - delete file.previewStatus; - _imageCache.set(ref.filePath, { ...file }); - updated = true; - } - } - } - } - - if (updated) saveImageCache(_imageCache); - return updated; -} - -function markMissingImagePreviewsUnavailable(messages: RawMessage[]): boolean { - let updated = false; - for (const msg of messages) { - if (!msg._attachedFiles) continue; - for (const file of msg._attachedFiles) { - if (!file.mimeType.startsWith('image/')) continue; - if (file.preview || file.previewStatus === 'unavailable') continue; - if (!file.filePath && !file.gatewayUrl) continue; - file.previewStatus = 'unavailable'; - updated = true; - } - } - return updated; -} - -/** - * Async: load missing previews from disk via IPC for messages that have - * _attachedFiles with null previews. Updates messages in-place and triggers re-render. - * Handles both [media attached: ...] patterns and raw filePath entries. - */ -async function loadMissingPreviews(messages: RawMessage[]): Promise { - // See helpers.ts loadMissingPreviews for the canonical comment block — - // this monolithic copy is kept in sync so legacy chat.ts callers also - // resolve Gateway-injected outgoing media URLs into local previews. - let updatedAny = false; - let attempt = 0; - - while (true) { - const needPreview = collectMissingPreviewRefs(messages); - if (needPreview.length === 0) return updatedAny; - if (attempt > 0) { - const delayMs = IMAGE_PREVIEW_RETRY_DELAYS_MS[attempt - 1]; - if (delayMs) await waitForPreviewRetry(delayMs); - } - - try { - const thumbnails = await hostApi.media.thumbnails({ - paths: needPreview, - }); - if (applyPreviewResults(messages, thumbnails)) { - updatedAny = true; - } - } catch (err) { - console.warn('[loadMissingPreviews] Failed:', err); - return updatedAny; - } - - if (!collectMissingPreviewRefs(messages).some((ref) => ref.mimeType.startsWith('image/'))) { - return updatedAny; - } - if (attempt >= IMAGE_PREVIEW_RETRY_DELAYS_MS.length) { - return markMissingImagePreviewsUnavailable(messages) || updatedAny; - } - attempt += 1; - } -} - -function getCanonicalPrefixFromSessions(sessions: ChatSession[]): string | null { - const canonical = sessions.find((s) => s.key.startsWith('agent:'))?.key; - if (!canonical) return null; - const parts = canonical.split(':'); +function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null { + if (!sessionKey.startsWith('agent:')) return null; + const parts = sessionKey.split(':'); if (parts.length < 2) return null; return `${parts[0]}:${parts[1]}`; } +function getCanonicalPrefixFromSessions(sessions: ChatSession[]): string | null { + const canonical = sessions.find((session) => session.key.startsWith('agent:'))?.key; + return canonical ? getCanonicalPrefixFromSessionKey(canonical) : null; +} + function getAgentIdFromSessionKey(sessionKey: string): string { if (!sessionKey.startsWith('agent:')) return 'main'; - const parts = sessionKey.split(':'); - return parts[1] || 'main'; -} - -function sessionIndicatesIdle(session: ChatSession | undefined): boolean { - if (!session) return false; - if (session.hasActiveRun === false) return true; - return session.status === 'done' - || session.status === 'completed' - || session.status === 'finished' - || session.status === 'failed' - || session.status === 'error' - || session.status === 'aborted' - || session.status === 'cancelled'; -} - -function reconcileCurrentSessionIdleFromBackend( - set: (partial: Partial | ((state: ChatState) => Partial)) => void, - get: () => ChatState, - sessions: ChatSession[], -): void { - const state = get(); - if (!state.sending && state.activeRunId == null && !state.pendingFinal) return; - - const current = sessions.find((session) => session.key === state.currentSessionKey); - if (!sessionIndicatesIdle(current)) return; - - // Avoid clearing a brand-new send from stale sessions.list metadata. The - // backend's session row must have been updated at or after the user message - // that armed the renderer run state. - if ( - state.lastUserMessageAt != null - && typeof current?.updatedAt === 'number' - && current.updatedAt < toMs(state.lastUserMessageAt) - ) { - return; - } - - set({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingToolImages: [], - }); -} - -async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise { - if (!isCronSessionKey(sessionKey)) return []; - try { - return await fetchCronSessionHistory(sessionKey, limit); - } catch (error) { - console.warn('Failed to load cron fallback history:', error); - return []; - } -} - -async function fetchChatSessionsList(): Promise> { - return useGatewayStore.getState().rpc>('sessions.list', { - includeDerivedTitles: true, - includeLastMessage: true, - }); -} - -async function fetchChatHistory( - sessionKey: string, - limit: number, - maxChars?: number, - timeoutMs?: number, -): Promise> { - const params = { - sessionKey, - limit, - ...(typeof maxChars === 'number' ? { maxChars } : {}), - }; - return useGatewayStore.getState().rpc>('chat.history', params, timeoutMs); -} - -async function sendChatMessageViaHostApi(params: { - sessionKey: string; - message: string; - deliver?: boolean; - idempotencyKey: string; -}): Promise<{ runId?: string }> { - return useGatewayStore.getState().rpc<{ runId?: string }>('chat.send', params, 120000); -} - -async function abortChatRunViaHostApi(sessionKey: string): Promise { - await useGatewayStore.getState().rpc('chat.abort', { sessionKey }); -} - -function normalizeAgentId(value: string | undefined | null): string { - return (value ?? '').trim().toLowerCase() || 'main'; -} - -function buildFallbackMainSessionKey(agentId: string): string { - return `agent:${normalizeAgentId(agentId)}:main`; -} - -function resolveMainSessionKeyForAgent(agentId: string | undefined | null): string | null { - if (!agentId) return null; - const normalizedAgentId = normalizeAgentId(agentId); - const summary = useAgentsStore.getState().agents.find((agent) => agent.id === normalizedAgentId); - return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId); + return sessionKey.split(':')[1] || 'main'; } function ensureSessionEntry( @@ -2123,1093 +302,707 @@ function clearSessionEntryFromMap>(entries: T, } function buildSessionSwitchPatch( - state: Pick< - ChatState, - | 'currentSessionKey' - | 'messages' - | 'sessions' - | 'sessionLabels' - | 'sessionLastActivity' - | 'thinkingLevel' - | 'sending' - | 'activeRunId' - | 'pendingFinal' - | 'lastUserMessageAt' - | 'streamingText' - | 'streamingMessage' - | 'streamingTools' - | 'pendingToolImages' - >, + state: Pick, nextSessionKey: string, options: { createdLocally?: boolean; workspacePath?: string } = {}, ): Partial { - captureSessionRunState(state.currentSessionKey, state); - if (state.messages.length > 0) { - cacheSessionHistory( - state.currentSessionKey, - cloneHistoryMessages(state.messages), - state.thinkingLevel ?? null, - ); - } - // Only treat sessions with no history records and no activity timestamp as empty. - // Relying solely on messages.length is unreliable because switchSession clears - // the current messages before loadHistory runs, creating a race condition that - // could cause sessions with real history to be incorrectly removed from the sidebar. - const leavingEmpty = !state.currentSessionKey.endsWith(':main') - && state.messages.length === 0 - && !state.sessionLastActivity[state.currentSessionKey] - && !state.sessionLabels[state.currentSessionKey] - && !hasExplicitSessionLabel(state.sessions.find((session) => session.key === state.currentSessionKey)); - - const nextSessions = leavingEmpty + const leavingLocalPlaceholder = state.currentSessionKey !== nextSessionKey + && state.sessions.some((session) => ( + session.key === state.currentSessionKey && session.createdLocally === true + )); + const sessions = leavingLocalPlaceholder ? state.sessions.filter((session) => session.key !== state.currentSessionKey) : state.sessions; - const cachedNextSession = getCachedSessionHistory(nextSessionKey); - const cachedRunState = getCachedSessionRunState(nextSessionKey); return { currentSessionKey: nextSessionKey, currentAgentId: getAgentIdFromSessionKey(nextSessionKey), - sessions: ensureSessionEntry(nextSessions, nextSessionKey, options), - sessionLabels: leavingEmpty + sessions: ensureSessionEntry(sessions, nextSessionKey, options), + sessionLabels: leavingLocalPlaceholder ? clearSessionEntryFromMap(state.sessionLabels, state.currentSessionKey) : state.sessionLabels, - sessionLastActivity: leavingEmpty + sessionLastActivity: leavingLocalPlaceholder ? clearSessionEntryFromMap(state.sessionLastActivity, state.currentSessionKey) : state.sessionLastActivity, - messages: cachedNextSession?.messages ?? [], - hasMoreHistory: cachedNextSession ? cachedNextSession.messages.length >= HISTORY_PAGE_SIZE : false, - loadingMoreHistory: false, - thinkingLevel: cachedNextSession?.thinkingLevel ?? state.thinkingLevel ?? null, - ...cachedRunState, - error: null, - runError: null, }; } -function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null { - if (!sessionKey.startsWith('agent:')) return null; - const parts = sessionKey.split(':'); - if (parts.length < 2) return null; - return `${parts[0]}:${parts[1]}`; -} - -function isToolOnlyMessage(message: RawMessage | undefined): boolean { - if (!message) return false; - if (isToolResultRole(message.role)) return true; - - const msg = message as unknown as Record; - const content = message.content; - - // Check OpenAI-format tool_calls field (real-time streaming from OpenAI-compatible models) - const toolCalls = msg.tool_calls ?? msg.toolCalls; - const hasOpenAITools = Array.isArray(toolCalls) && toolCalls.length > 0; - - if (!Array.isArray(content)) { - // Content is not an array — check if there's OpenAI-format tool_calls - if (hasOpenAITools) { - // Has tool calls but content might be empty/string — treat as tool-only - // if there's no meaningful text content - const textContent = typeof content === 'string' ? content.trim() : ''; - return textContent.length === 0; - } - return false; +function repairMissingCurrentSelection( + currentSessionKey: string, + sessions: ChatSession[], +): { + sessions: ChatSession[]; + currentSessionKey: string; + currentAgentId: string; +} { + if (sessions.some((session) => session.key === currentSessionKey)) { + return { + sessions, + currentSessionKey, + currentAgentId: getAgentIdFromSessionKey(currentSessionKey), + }; } - let hasTool = hasOpenAITools; - let hasText = false; - let hasNonToolContent = false; - - for (const block of content as ContentBlock[]) { - if (block.type === 'tool_use' || block.type === 'tool_result' || block.type === 'toolCall' || block.type === 'toolResult') { - hasTool = true; - continue; - } - if (block.type === 'text' && block.text && block.text.trim()) { - hasText = true; - continue; - } - // Only actual image output disqualifies a tool-only message. - // Thinking blocks are internal reasoning that can accompany tool_use — they - // should NOT prevent the message from being treated as an intermediate tool step. - if (block.type === 'image') { - hasNonToolContent = true; - } + const fallbackKey = pickStartupSessionFallback(currentSessionKey, sessions); + if (fallbackKey) { + return { + sessions, + currentSessionKey: fallbackKey, + currentAgentId: getAgentIdFromSessionKey(fallbackKey), + }; } - return hasTool && !hasText && !hasNonToolContent; -} - -function isToolResultRole(role: unknown): boolean { - if (!role) return false; - const normalized = String(role).toLowerCase(); - return normalized === 'toolresult' || normalized === 'tool_result'; -} - -/** True for internal plumbing messages that should never be shown in the UI. */ -function isInternalMessage(msg: { role?: unknown; content?: unknown; idempotencyKey?: unknown; model?: unknown; text?: unknown }): boolean { - if (msg.role === 'system') return true; - const text = getMessageTextForFilter(msg); - if (msg.role === 'assistant') { - if (isInternalAssistantReplyText(text)) return true; - if (isGeneratingStatusNarration(text)) return true; - // OpenClaw's gateway writes a fallback `assistant-media` transcript - // message when its `createManagedOutgoingImageBlocks` pipeline fails - // ("could not be prepared" warning in stderr). The fallback has: - // - model: 'gateway-injected' - // - idempotencyKey: ':assistant-media' - // - content: text-only `MEDIA:` (NOT an image-url block) - // The staging path lives in `~/.openclaw/media/outbound/` which has a - // 120s TTL — the file is gone by the time the user reads the chat. - // The original `MEDIA:/tmp/...` path is already on the previous - // assistant message (the agent's actual reply), so the fallback is - // pure duplicate noise. Hide it in the UI so it neither shows a - // broken card nor competes with the real reply for layout space. - const idempotencyKey = typeof msg.idempotencyKey === 'string' ? msg.idempotencyKey : ''; - const isGatewayInjectedFallback = msg.model === 'gateway-injected' - && idempotencyKey.endsWith(':assistant-media'); - if (isGatewayInjectedFallback) { - const hasImageUrlBlock = Array.isArray(msg.content) - && (msg.content as ContentBlock[]).some( - (block) => block.type === 'image' && typeof block.url === 'string' && !!block.url, - ); - // Real gateway-media bubbles (with an image-url block) ARE the - // canonical render — keep them. Only hide the text-only fallback. - if (!hasImageUrlBlock) return true; - } - if (!text.trim() && Array.isArray(msg.content)) { - const blocks = msg.content as ContentBlock[]; - const hasThinking = blocks.some((block) => block.type === 'thinking' && block.thinking?.trim()); - const hasVisibleText = blocks.some((block) => block.type === 'text' && block.text?.trim()); - if (hasThinking && !hasVisibleText) return true; - } - } - if (msg.role === 'user' && /^\[OpenClaw heartbeat poll\]\s*$/i.test(text.trim())) return true; - // Runtime system injections: these arrive as user or assistant-role messages - // but are internal plumbing (exec results, async-command notices, time pings, etc.) - if ((msg.role === 'user' || msg.role === 'assistant') && isRuntimeSystemInjection(text)) return true; - return false; -} - -/** - * Detect runtime-injected system messages that should be hidden from the chat UI. - * These are injected by the OpenClaw runtime as user-role messages and include: - * - "System (untrusted): ..." — exec results, tool output, etc. - * - "An async command you ran earlier has completed" — async completion notices - * - "Current time: ..." followed by nothing else — periodic heartbeat time pings - * - "Handle the result internally. Do not relay it to the user" — internal directives - */ -function isRuntimeSystemInjection(text: string): boolean { - if (!text) return false; - const normalized = text.trim(); - if (/^\s*System\s*\(untrusted\)\s*:/i.test(normalized)) return true; - if ( - /An async command you ran earlier has completed/i.test(normalized) - && /Do not relay it to the user unless explicitly requested/i.test(normalized) + const prefix = getCanonicalPrefixFromSessionKey(currentSessionKey) + ?? getCanonicalPrefixFromSessions(sessions) + ?? DEFAULT_CANONICAL_PREFIX; + let suffix = Date.now(); + let placeholderKey = `${prefix}:session-${suffix}`; + while ( + placeholderKey === currentSessionKey + || sessions.some((session) => session.key === placeholderKey) ) { - return true; + suffix += 1; + placeholderKey = `${prefix}:session-${suffix}`; } - if (/^\[Inter-session message\]/i.test(normalized)) return true; - if (isOpenClawRuntimeEventPrompt(normalized)) return true; - - if ( - /^\s*Current time\s*:/i.test(normalized) - && /^\s*Current time\s*:[^\n]*\/\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+UTC\s*$/i.test(normalized) - ) { - return true; - } - return false; + return { + sessions: ensureSessionEntry(sessions, placeholderKey, { createdLocally: true }), + currentSessionKey: placeholderKey, + currentAgentId: getAgentIdFromSessionKey(placeholderKey), + }; } -// ── Write tool_use baseline capture ───────────────────────────── -// -// Tool name sets mirror generated-files.ts so we detect the same tools. -const BASELINE_WRITE_TOOLS = new Set([ - 'Write', 'write_file', 'create_file', 'WriteFile', 'createFile', 'write', -]); -const BASELINE_EDIT_TOOLS = new Set([ - 'Edit', 'edit', 'edit_file', 'EditFile', - 'StrReplace', 'str_replace', 'str_replace_editor', - 'MultiEdit', 'multi_edit', 'multiEdit', -]); -const BASELINE_FILE_PATH_KEYS = ['file_path', 'filepath', 'path', 'fileName', 'file_name', 'target_path']; - -function pickFilePathFromInput(input: unknown): string | null { - if (!input || typeof input !== 'object') return null; - const rec = input as Record; - for (const key of BASELINE_FILE_PATH_KEYS) { - const value = rec[key]; - if (typeof value === 'string' && value.trim()) return value.trim(); - } - return null; +function getRemovedSessionKeys(before: ChatSession[], after: ChatSession[]): Set { + const remainingKeys = new Set(after.map((session) => session.key)); + return new Set(before.filter((session) => !remainingKeys.has(session.key)).map((session) => session.key)); } -/** - * Scan a streaming message for Write/Edit tool_use blocks and trigger - * async baseline reads from disk for each target file. Called on every - * `delta` event; `captureBaseline` is idempotent — duplicate calls for - * the same path are no-ops. - */ -function isBaselineRealUserMessage(message: RawMessage | undefined): boolean { - if (!message || message.role !== 'user') return false; - const content = message.content; - if (!Array.isArray(content)) return true; - const blocks = content as Array<{ type?: string }>; - return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); -} +function applySessionLabelSummaries( + set: ChatSet, + summaries: SessionLabelSummary[], + requestVersions?: ReadonlyMap, +): void { + if (summaries.length === 0) return; + set((state) => { + let nextLabels = state.sessionLabels; + let nextActivity = state.sessionLastActivity; + let nextSessions = state.sessions; + let switchPatch: Partial | null = null; + let changed = false; -function countBaselineRealUserMessages(messages: RawMessage[]): number { - let count = 0; - for (const message of messages) { - if (isBaselineRealUserMessage(message)) count += 1; - } - return count; -} + for (const summary of summaries) { + const session = nextSessions.find((entry) => entry.key === summary.sessionKey); + if (requestVersions) { + const requestVersion = requestVersions.get(summary.sessionKey); + if ( + !requestVersion + || !session + || !isSessionLabelHydrationVersionCurrent(summary.sessionKey, requestVersion) + || getSessionLabelHydrationVersion(session, nextActivity) !== requestVersion + ) continue; + } -function getBaselineRunKeyForMessages(sessionKey: string, messages: RawMessage[]): string | null { - const userTurnOrdinal = countBaselineRealUserMessages(messages); - return buildBaselineRunKey(sessionKey, userTurnOrdinal); -} + if (isHeartbeatOnlySummaryForSession(session, summary)) { + nextSessions = nextSessions.filter((entry) => entry.key !== summary.sessionKey); + nextLabels = clearSessionEntryFromMap(nextLabels, summary.sessionKey); + nextActivity = clearSessionEntryFromMap(nextActivity, summary.sessionKey); + changed = true; -function captureBaselinesFromMessage(message: unknown, runKey: string | null): void { - if (!runKey || !message || typeof message !== 'object') return; - const content = (message as Record).content; - if (!Array.isArray(content)) return; - for (const block of content as ContentBlock[]) { - if (block.type !== 'tool_use' && block.type !== 'toolCall') continue; - const name = typeof block.name === 'string' ? block.name : ''; - if (!name) continue; - if (!BASELINE_WRITE_TOOLS.has(name) && !BASELINE_EDIT_TOOLS.has(name)) continue; - const input = block.input ?? block.arguments; - const filePath = pickFilePathFromInput(input); - if (filePath) captureBaseline(runKey, filePath); - } -} + if (state.currentSessionKey === summary.sessionKey) { + const prefix = getCanonicalPrefixFromSessionKey(summary.sessionKey) ?? DEFAULT_CANONICAL_PREFIX; + switchPatch = buildSessionSwitchPatch( + { + ...state, + sessions: nextSessions, + sessionLabels: nextLabels, + sessionLastActivity: nextActivity, + }, + `${prefix}:session-${Date.now()}`, + { createdLocally: true }, + ); + if (switchPatch.sessions) nextSessions = switchPatch.sessions; + if (switchPatch.sessionLabels) nextLabels = switchPatch.sessionLabels; + if (switchPatch.sessionLastActivity) nextActivity = switchPatch.sessionLastActivity; + } + continue; + } -function extractTextFromContent(content: unknown): string { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - const parts: string[] = []; - for (const block of content as ContentBlock[]) { - if (block.type === 'text' && block.text) { - parts.push(block.text); - } - } - return parts.join('\n'); -} + const labelText = toAutomaticSessionLabel(summary.firstUserText || ''); + const existingLabel = nextLabels[summary.sessionKey]?.trim(); + const existingLabelIsFallback = isSyntheticSessionFallbackLabel(session, existingLabel); + if (labelText && (!existingLabel || existingLabelIsFallback) && !hasExplicitSessionLabel(session)) { + if (nextLabels === state.sessionLabels) nextLabels = { ...state.sessionLabels }; + nextLabels[summary.sessionKey] = labelText; + changed = true; + } -function summarizeToolOutput(text: string): string | undefined { - const trimmed = text.trim(); - if (!trimmed) return undefined; - const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - if (lines.length === 0) return undefined; - const summaryLines = lines.slice(0, 2); - let summary = summaryLines.join(' / '); - if (summary.length > 160) { - summary = `${summary.slice(0, 157)}...`; - } - return summary; -} + if (typeof summary.lastTimestamp === 'number' && Number.isFinite(summary.lastTimestamp)) { + if (nextActivity[summary.sessionKey] !== summary.lastTimestamp) { + if (nextActivity === state.sessionLastActivity) nextActivity = { ...state.sessionLastActivity }; + nextActivity[summary.sessionKey] = summary.lastTimestamp; + changed = true; + } + } -function normalizeToolStatus(rawStatus: unknown, fallback: 'running' | 'completed'): ToolStatus['status'] { - const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : ''; - if (status === 'error' || status === 'failed') return 'error'; - if (status === 'completed' || status === 'success' || status === 'done') return 'completed'; - return fallback; -} - -function parseDurationMs(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - const parsed = typeof value === 'string' ? Number(value) : NaN; - return Number.isFinite(parsed) ? parsed : undefined; -} - -function extractToolUseUpdates(message: unknown): ToolStatus[] { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - const updates: ToolStatus[] = []; - - // Path 1: Anthropic/normalized format — tool blocks inside content array - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if ((block.type !== 'tool_use' && block.type !== 'toolCall') || !block.name) continue; - updates.push({ - id: block.id || block.name, - toolCallId: block.id, - name: block.name, - status: 'running', - updatedAt: Date.now(), - }); - } - } - - // Path 2: OpenAI format — tool_calls array on the message itself - if (updates.length === 0) { - const toolCalls = msg.tool_calls ?? msg.toolCalls; - if (Array.isArray(toolCalls)) { - for (const tc of toolCalls as Array>) { - const fn = (tc.function ?? tc) as Record; - const name = typeof fn.name === 'string' ? fn.name : ''; - if (!name) continue; - const id = typeof tc.id === 'string' ? tc.id : name; - updates.push({ - id, - toolCallId: typeof tc.id === 'string' ? tc.id : undefined, - name, - status: 'running', - updatedAt: Date.now(), - }); + const workspacePath = typeof summary.workspacePath === 'string' ? summary.workspacePath.trim() : ''; + if (workspacePath) { + const sessionIndex = nextSessions.findIndex((entry) => entry.key === summary.sessionKey); + const existingSession = sessionIndex >= 0 ? nextSessions[sessionIndex] : undefined; + if (existingSession && existingSession.workspacePath !== workspacePath) { + if (nextSessions === state.sessions) nextSessions = [...state.sessions]; + nextSessions[sessionIndex] = { ...existingSession, workspacePath }; + changed = true; + } } } - } - return updates; -} - -function extractToolResultBlocks(message: unknown, eventState: string): ToolStatus[] { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - const content = msg.content; - if (!Array.isArray(content)) return []; - - const updates: ToolStatus[] = []; - for (const block of content as ContentBlock[]) { - if (block.type !== 'tool_result' && block.type !== 'toolResult') continue; - const outputText = extractTextFromContent(block.content ?? block.text ?? ''); - const summary = summarizeToolOutput(outputText); - updates.push({ - id: block.id || block.name || 'tool', - toolCallId: block.id, - name: block.name || block.id || 'tool', - status: normalizeToolStatus(undefined, eventState === 'delta' ? 'running' : 'completed'), - summary, - updatedAt: Date.now(), - }); - } - - return updates; -} - -function extractToolResultUpdate(message: unknown, eventState: string): ToolStatus | null { - if (!message || typeof message !== 'object') return null; - const msg = message as Record; - const role = typeof msg.role === 'string' ? msg.role.toLowerCase() : ''; - if (!isToolResultRole(role)) return null; - - const toolName = typeof msg.toolName === 'string' ? msg.toolName : (typeof msg.name === 'string' ? msg.name : ''); - const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined; - const details = (msg.details && typeof msg.details === 'object') ? msg.details as Record : undefined; - const rawStatus = (msg.status ?? details?.status); - const fallback = eventState === 'delta' ? 'running' : 'completed'; - const status = normalizeToolStatus(rawStatus, fallback); - const durationMs = parseDurationMs(details?.durationMs ?? details?.duration ?? (msg as Record).durationMs); - - const outputText = (details && typeof details.aggregated === 'string') - ? details.aggregated - : extractTextFromContent(msg.content); - const summary = summarizeToolOutput(outputText) ?? summarizeToolOutput(String(details?.error ?? msg.error ?? '')); - - const name = toolName || toolCallId || 'tool'; - const id = toolCallId || name; - - return { - id, - toolCallId, - name, - status, - durationMs, - summary, - updatedAt: Date.now(), - }; -} - -function mergeToolStatus(existing: ToolStatus['status'], incoming: ToolStatus['status']): ToolStatus['status'] { - const order: Record = { running: 0, completed: 1, error: 2 }; - return order[incoming] >= order[existing] ? incoming : existing; -} - -function upsertToolStatuses(current: ToolStatus[], updates: ToolStatus[]): ToolStatus[] { - if (updates.length === 0) return current; - const next = [...current]; - for (const update of updates) { - const key = update.toolCallId || update.id || update.name; - if (!key) continue; - const index = next.findIndex((tool) => (tool.toolCallId || tool.id || tool.name) === key); - if (index === -1) { - next.push(update); - continue; + if (!changed) return {}; + if (nextSessions !== state.sessions || switchPatch?.currentSessionKey) { + markLocalSessionCatalogMutation(); } - const existing = next[index]; - next[index] = { - ...existing, - ...update, - name: update.name || existing.name, - status: mergeToolStatus(existing.status, update.status), - durationMs: update.durationMs ?? existing.durationMs, - summary: update.summary ?? existing.summary, - updatedAt: update.updatedAt || existing.updatedAt, - }; - } - return next; -} - -/** - * Only treat an explicit chat.send ack timeout as recoverable. - * Gateway stopped / Gateway not connected are hard failures that - * should still terminate the send immediately. - */ -function isRecoverableChatSendTimeout(error: string): boolean { - return error.includes('RPC timeout: chat.send'); -} - -function collectToolUpdates(message: unknown, eventState: string): ToolStatus[] { - const updates: ToolStatus[] = []; - const toolResultUpdate = extractToolResultUpdate(message, eventState); - if (toolResultUpdate) updates.push(toolResultUpdate); - updates.push(...extractToolResultBlocks(message, eventState)); - updates.push(...extractToolUseUpdates(message)); - return updates; -} - -/** - * True when an assistant message carries user-visible final output (text or - * image). NOTE: `thinking` blocks are intentionally excluded — they are the - * model's internal monologue and frequently precede tool calls in models like - * MiniMax-M2.7 and gpt-5.5. Treating thinking as "final content" causes the - * history-poll closer in applyLoadedMessages and the runtime final handler to - * misclassify intermediate `[thinking, toolCall]` turns as completed replies, - * which prematurely tears down the `sending` / `activeRunId` / `pendingFinal` - * lifecycle flags and makes the Thinking… indicator vanish mid-tool-chain. - */ -function messageHasImageContent(message: RawMessage | undefined): boolean { - if (!message) return false; - if ((message._attachedFiles ?? []).some((file) => file.mimeType.startsWith('image/'))) return true; - const content = message.content; - return Array.isArray(content) && (content as ContentBlock[]).some((block) => block.type === 'image'); -} - -function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { - if (!message) return false; - if (typeof message.content === 'string' && message.content.trim()) return true; - - const content = message.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if (block.type === 'text' && block.text && block.text.trim()) return true; - if (block.type === 'image') return true; - } - } - if (messageHasImageContent(message)) return true; - - const msg = message as unknown as Record; - if (typeof msg.text === 'string' && msg.text.trim()) return true; - - return false; -} - -/** - * True when an assistant message is still waiting on a tool result, i.e. it - * represents an intermediate tool-use turn rather than a finished reply. - * Detected via: - * - explicit stop_reason = "tool_use" / "toolUse" - * - any tool_use / toolCall block in `content` - * - OpenAI-format `tool_calls` array - * Used by applyLoadedMessages and the runtime `final` handler to keep the - * `sending` / `activeRunId` / `pendingFinal` flags armed across tool rounds. - */ -function hasPendingToolUse(message: RawMessage | undefined): boolean { - if (!message) return false; - const reason = getMessageStopReason(message); - if (reason === 'tool_use' || reason === 'tooluse') return true; - - const content = message.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if (block.type === 'tool_use' || block.type === 'toolCall') return true; - } - } - - const msg = message as unknown as Record; - const toolCalls = msg.tool_calls ?? msg.toolCalls; - if (Array.isArray(toolCalls) && toolCalls.length > 0) return true; - - return false; -} - -function isRealUserBoundaryMessage(msg: RawMessage): boolean { - if (msg.role !== 'user') return false; - if (!Array.isArray(msg.content)) return true; - const blocks = msg.content as Array<{ type?: string }>; - return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); -} - -function segmentHasMeaningfulAssistantProgress(segment: RawMessage[]): boolean { - return segment.some((msg) => { - if (msg.role !== 'assistant') return false; - if (isTerminalAssistantErrorMessage(msg)) return true; - if (hasPendingToolUse(msg) || isToolOnlyMessage(msg)) return true; - return hasNonToolAssistantContent(msg); + const patch: Partial = switchPatch ? { ...switchPatch } : {}; + if (nextLabels !== state.sessionLabels) patch.sessionLabels = nextLabels; + if (nextActivity !== state.sessionLastActivity) patch.sessionLastActivity = nextActivity; + if (nextSessions !== state.sessions) patch.sessions = nextSessions; + return patch; }); } -/** True when the post-user segment has real run output (not a thinking-only stub). */ -function hasMeaningfulAssistantProgressAfterLastUser(messages: RawMessage[]): boolean { - return segmentHasMeaningfulAssistantProgress(postUserSegmentMessages(messages)); +function isGatewayStartupError(error: unknown): boolean { + const message = String(error).toLowerCase(); + return message.includes('unavailable during gateway startup') + || message.includes('unavailable during startup') + || message.includes('not yet ready') + || message.includes('service not initialized'); } -/** True when streaming state carries visible progress (not a role-only placeholder). */ -function hasMeaningfulStreamingActivity( - streamingMessage: unknown | null, - streamingText: string, - streamingTools: ToolStatus[], -): boolean { - if (streamingText.trim()) return true; - if (streamingTools.length > 0) return true; - if (!streamingMessage || typeof streamingMessage !== 'object') return false; - - const msg = streamingMessage as RawMessage; - if (typeof msg.content === 'string' && msg.content.trim()) return true; - - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if (block.type === 'text' && block.text?.trim()) return true; - if (block.type === 'thinking' && block.thinking?.trim()) return true; - if (block.type === 'tool_use' || block.type === 'toolCall') return true; - if (block.type === 'image') return true; - } - } - - const raw = msg as unknown as Record; - if (typeof raw.text === 'string' && raw.text.trim()) return true; - const toolCalls = raw.tool_calls ?? raw.toolCalls; - return Array.isArray(toolCalls) && toolCalls.length > 0; +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); } -function hasAssistantProgressSinceSend(messages: RawMessage[], lastUserMessageAt: number | null): boolean { - if (!lastUserMessageAt) return false; - const normalized = [...messages]; - while (normalized.length > 0) { - const last = normalized[normalized.length - 1]; - if (last.role === 'user' && !last.timestamp) { - normalized.pop(); - continue; - } - break; - } - return hasMeaningfulAssistantProgressAfterLastUser(normalized); -} - -function postUserSegmentMessages(filteredMessages: RawMessage[]): RawMessage[] { - for (let i = filteredMessages.length - 1; i >= 0; i -= 1) { - if (isRealUserBoundaryMessage(filteredMessages[i])) { - return filteredMessages.slice(i + 1); - } - } - return []; -} - -/** Segment after the user turn that matches the in-flight send (not prior history). */ -function getOpenRunSegmentFromHistory( - filteredMessages: RawMessage[], - lastUserMessageAt: number | null, -): RawMessage[] { - if (lastUserMessageAt == null) { - return postUserSegmentMessages(filteredMessages); - } - const userMsTs = toMs(lastUserMessageAt); - const CLOCK_SKEW_MS = 5_000; - for (let i = filteredMessages.length - 1; i >= 0; i -= 1) { - const message = filteredMessages[i]; - if (!isRealUserBoundaryMessage(message)) continue; - const ts = message.timestamp ? toMs(message.timestamp as number) : null; - if (ts == null) continue; - if (ts + CLOCK_SKEW_MS >= userMsTs && ts <= userMsTs + OPTIMISTIC_USER_TIMESTAMP_MATCH_MS) { - return filteredMessages.slice(i + 1); - } - } - return []; -} - -/** Only treat inbound runs as user-visible for this long after the last user send. */ -const USER_INITIATED_RUN_MAX_AGE_MS = 10 * 60 * 1000; - -function hasCachedActiveUserRun(sessionKey: string): boolean { - const cached = getCachedSessionRunState(sessionKey); - return cached.sending || cached.activeRunId != null || cached.pendingFinal; -} - -function shouldTrackInboundRunLifecycle( - state: Pick, - sessionKey?: string, -): boolean { - if (state.sending || state.activeRunId != null || state.pendingFinal) return true; - if (sessionKey && hasCachedActiveUserRun(sessionKey)) return true; - // Cron sessions are explicit, user-scheduled tasks. When the user is viewing - // the cron session and it fires, surface the live running state — unlike the - // background :main heartbeat runs this guard otherwise suppresses. - if (sessionKey && isCronSessionKey(sessionKey)) return true; - if (!state.lastUserMessageAt) return false; - return Date.now() - toMs(state.lastUserMessageAt) <= USER_INITIATED_RUN_MAX_AGE_MS; -} - -function isFailedAssistantTurnMessage(message: RawMessage | unknown): boolean { - if (!message || typeof message !== 'object') return false; - const msg = message as RawMessage; - if (msg.role !== 'assistant') return false; - return /\[assistant turn failed/i.test(getMessageText(msg.content)); -} - -function segmentHasOpenToolRun(segmentMessages: RawMessage[]): boolean { - if (segmentMessages.length === 0) return false; - const hasToolActivity = segmentMessages.some( - (message) => message.role === 'assistant' && (hasPendingToolUse(message) || isToolOnlyMessage(message)), - ); - if (!hasToolActivity) return false; - - let lastToolUseOffset = -1; - for (let i = segmentMessages.length - 1; i >= 0; i -= 1) { - const message = segmentMessages[i]; - if (message.role === 'assistant' && (hasPendingToolUse(message) || isToolOnlyMessage(message))) { - lastToolUseOffset = i; - break; - } - } - - // The tool run is closed if any assistant message after the last tool call - // is a non-tool response — either with visible content or a thinking-only - // terminal turn (the model ended without producing more tool calls). - return !segmentMessages.some((message, index) => { - if (index <= lastToolUseOffset) return false; - if (message.role !== 'assistant') return false; - if (hasPendingToolUse(message)) return false; - if (hasNonToolAssistantContent(message)) return true; - return !isToolOnlyMessage(message); +async function fetchChatSessionsList(): Promise> { + return useGatewayStore.getState().rpc>('sessions.list', { + includeDerivedTitles: true, + includeLastMessage: true, }); } -function findLastRealUserMessage(messages: RawMessage[]): RawMessage | null { - for (let i = messages.length - 1; i >= 0; i -= 1) { - if (isRealUserBoundaryMessage(messages[i])) { - return messages[i]; - } - } - return null; -} - -function dedupeAttachedFiles(files: AttachedFileMeta[]): AttachedFileMeta[] { - const seen = new Set(); - const next: AttachedFileMeta[] = []; - for (const file of files) { - const key = file.filePath || `${file.fileName}|${file.mimeType}|${file.preview || ''}`; - if (seen.has(key)) continue; - seen.add(key); - next.push(file); - } - return next; -} - -function runtimeToolEventToStatus(event: ChatRuntimeEvent): ToolStatus | null { - if (event.type === 'tool.started') { - return { - id: event.toolCallId, - toolCallId: event.toolCallId, - name: event.name, - status: 'running', - summary: typeof event.args === 'string' ? event.args : undefined, - updatedAt: event.ts ?? Date.now(), - }; - } - if (event.type === 'tool.updated') { - return { - id: event.toolCallId, - toolCallId: event.toolCallId, - name: event.name, - status: 'running', - summary: typeof event.partialResult === 'string' ? event.partialResult : undefined, - updatedAt: event.ts ?? Date.now(), - }; - } - if (event.type === 'tool.completed') { - return { - id: event.toolCallId, - toolCallId: event.toolCallId, - name: event.name, - status: event.isError ? 'error' : 'completed', - summary: typeof event.result === 'string' ? event.result : undefined, - updatedAt: event.ts ?? Date.now(), - }; - } - return null; -} - -// ── Store ──────────────────────────────────────────────────────── - export const useChatStore = create((set, get) => ({ - messages: [], - loading: false, - loadingMoreHistory: false, - hasMoreHistory: false, - error: null, - runError: null, - dismissedRunErrors: {}, - - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - runtimeRuns: {}, - sessions: [], currentSessionKey: DEFAULT_SESSION_KEY, currentAgentId: 'main', sessionLabels: {}, sessionLastActivity: {}, - thinkingLevel: null, - - // ── Load sessions via sessions.list ── - loadSessions: async (options) => { const requestedGeneration = options?.gatewayGeneration; if (typeof requestedGeneration === 'number' && Number.isFinite(requestedGeneration)) { - if (requestedGeneration < _sessionCatalogGeneration) return; + if (requestedGeneration < sessionCatalogGeneration) return; synchronizeGatewaySessionGeneration(requestedGeneration); } const now = Date.now(); - if (_loadSessionsInFlight) { - let observedFlight = _loadSessionsInFlight; + if (loadSessionsInFlight) { + let observedFlight = loadSessionsInFlight; if (options?.force) { - _queuedForcedSessionGeneration = Math.max( - _queuedForcedSessionGeneration ?? _sessionCatalogGeneration, - _sessionCatalogGeneration, + queuedForcedSessionGeneration = Math.max( + queuedForcedSessionGeneration ?? sessionCatalogGeneration, + sessionCatalogGeneration, ); } while (observedFlight) { await observedFlight; if (!options?.force) break; - const successorFlight = _loadSessionsInFlight; + const successorFlight = loadSessionsInFlight; if (!successorFlight || successorFlight === observedFlight) break; observedFlight = successorFlight; } return; } - if (!options?.force && now - _lastLoadSessionsAt < SESSION_LOAD_MIN_INTERVAL_MS) { - return; - } + if (!options?.force && now - lastLoadSessionsAt < SESSION_LOAD_MIN_INTERVAL_MS) return; const runFlight = async (initialGeneration: number): Promise => { let generation = initialGeneration; let internalFollowUpUsed = false; let previousGatewayListKeys: Set | null = null; const suppressedSessionKeys = new Set(); + const advanceGeneration = (nextGeneration: number): void => { + if (nextGeneration !== generation) { + previousGatewayListKeys = null; + suppressedSessionKeys.clear(); + } + generation = nextGeneration; + }; + while (true) { - const pendingEvents = _pendingGenerationSessionEvents?.generation === generation - ? _pendingGenerationSessionEvents.events + const pendingEvents = pendingGenerationSessionEvents?.generation === generation + ? pendingGenerationSessionEvents.events : []; - if (_pendingGenerationSessionEvents?.generation === generation) { - _pendingGenerationSessionEvents = null; + if (pendingGenerationSessionEvents?.generation === generation) { + pendingGenerationSessionEvents = null; } const context = { generation, events: pendingEvents }; - _loadSessionsContext = context; + loadSessionsContext = context; let needsFollowUp = false; + try { + const localRevisionBeforeRequest = localSessionCatalogRevision; const data = await fetchChatSessionsList(); - if (data && generation === _sessionCatalogGeneration) { - const rawSessions = Array.isArray(data.sessions) ? data.sessions : []; - const normalizedSessions: ChatSession[] = rawSessions.map( - (session: Record) => normalizeGatewaySessionRow(session), - ); - const sessions = normalizedSessions.filter((s: ChatSession) => shouldIncludeSessionInSidebarList(s)); + if (generation === sessionCatalogGeneration) { + const rawSessions = Array.isArray(data.sessions) ? data.sessions : []; + const normalizedSessions = rawSessions.map((session) => ( + normalizeGatewaySessionRow(session as Record) + )); + const normalizedSessionKeys = new Set(normalizedSessions.map((session) => session.key)); + const sessions = normalizedSessions.filter(shouldIncludeSessionInSidebarList); - const canonicalBySuffix = new Map(); - for (const session of sessions) { - if (!session.key.startsWith('agent:')) continue; - const parts = session.key.split(':'); - if (parts.length < 3) continue; - const suffix = parts.slice(2).join(':'); - if (suffix && !canonicalBySuffix.has(suffix)) { - canonicalBySuffix.set(suffix, session.key); + const canonicalBySuffix = new Map(); + for (const session of sessions) { + if (!session.key.startsWith('agent:')) continue; + const parts = session.key.split(':'); + if (parts.length < 3) continue; + const suffix = parts.slice(2).join(':'); + if (suffix && !canonicalBySuffix.has(suffix)) canonicalBySuffix.set(suffix, session.key); } - } - // Deduplicate: if both short and canonical existed, keep canonical only - const seen = new Set(); - const dedupedSessions = sessions.filter((s) => { - if (!s.key.startsWith('agent:') && canonicalBySuffix.has(s.key)) return false; - if (seen.has(s.key)) return false; - seen.add(s.key); - return true; - }); + const seen = new Set(); + const dedupedSessions = sessions.filter((session) => { + if (!session.key.startsWith('agent:') && canonicalBySuffix.has(session.key)) return false; + if (seen.has(session.key)) return false; + seen.add(session.key); + return true; + }); - const { currentSessionKey, sessions: localSessions, sessionLabels: currentSessionLabels } = get(); - const localSessionByKey = new Map( - localSessions.map((session) => [session.key, session] as const), - ); - const mergedSessions = dedupedSessions.map((session) => { - const localSession = localSessionByKey.get(session.key); - const workspacePath = session.workspacePath ?? localSession?.workspacePath; - if (!localSession?.createdLocally && workspacePath === session.workspacePath) return session; - return { - ...session, - ...(workspacePath ? { workspacePath } : {}), - ...(localSession?.createdLocally ? { createdLocally: true } : {}), - }; - }); - if (previousGatewayListKeys) { - const currentKeys = new Set(localSessions.map((session) => session.key)); - for (const sessionKey of previousGatewayListKeys) { - if (!currentKeys.has(sessionKey)) suppressedSessionKeys.add(sessionKey); - } - } - previousGatewayListKeys = new Set(mergedSessions.map((session) => session.key)); - const heartbeatLabelCleanup = getHeartbeatCachedLabelCleanup(mergedSessions, currentSessionLabels); - for (const sessionKey of heartbeatLabelCleanup.hiddenSessionKeys) { - clearCachedSessionHistory(sessionKey); - clearCachedSessionRunState(sessionKey); - clearSessionLabelHydrationTracking(sessionKey); - } - let visibleMergedSessions = heartbeatLabelCleanup.hiddenSessionKeys.size > 0 - ? mergedSessions.filter((session) => !heartbeatLabelCleanup.hiddenSessionKeys.has(session.key)) - : mergedSessions; - if (suppressedSessionKeys.size > 0) { - visibleMergedSessions = visibleMergedSessions.filter( - (session) => !suppressedSessionKeys.has(session.key), + const { + currentSessionKey, + sessions: localSessions, + sessionLabels: currentSessionLabels, + sessionLastActivity: currentSessionActivity, + } = get(); + const localSessionByKey = new Map( + localSessions.map((session) => [session.key, session] as const), ); - } - const listTs = typeof data.ts === 'number' && Number.isFinite(data.ts) - ? data.ts - : undefined; - const uncertainty = getSessionEventUncertainty(context.events); - const toOrderableAttentionRows = (rows: ChatSession[]): ChatSession[] => ( - uncertainty.keys.size === 0 - ? rows - : rows.filter((session) => !uncertainty.keys.has(session.key)) - ); - const attentionTransitions: SessionAttentionTransition[] = [ - { type: 'rows', rows: toOrderableAttentionRows(visibleMergedSessions) }, - ]; - const deletedSessionKeys = new Set(); - const attentionOrderIsReliable = listTs !== undefined && !uncertainty.unscoped; - - if (listTs === undefined) { - needsFollowUp = true; - } else { - _successfulSessionListTsFloor = Math.max( - _successfulSessionListTsFloor ?? -Infinity, - listTs, - ); - for (const session of visibleMergedSessions) { - _latestSessionEventTsByKey.set( - session.key, - Math.max(_latestSessionEventTsByKey.get(session.key) ?? -Infinity, listTs), - ); - } - } - - for (const event of context.events) { - const eventTs = typeof event.ts === 'number' && Number.isFinite(event.ts) - ? event.ts - : undefined; - if (listTs === undefined || eventTs === undefined) { - needsFollowUp = true; - continue; - } - if (eventTs < listTs) continue; - - const result = applyGatewaySessionsChanged( - visibleMergedSessions, - event, - _latestSessionEventTsByKey, - ); - if (result.applied) { - visibleMergedSessions = result.sessions; - if (result.deletedKey) { - clearSessionLabelHydrationTracking(result.deletedKey); - if (!uncertainty.keys.has(result.deletedKey)) { - attentionTransitions.push({ type: 'delete', sessionKey: result.deletedKey }); - } - } else { - attentionTransitions.push({ - type: 'rows', - rows: toOrderableAttentionRows(visibleMergedSessions), - }); - } - } - if (result.deletedKey) deletedSessionKeys.add(result.deletedKey); - if (result.requiresReload) needsFollowUp = true; - } - - if (attentionOrderIsReliable) { - useSessionAttentionStore.getState().reconcileSessionTransitions(attentionTransitions); - } - const labelCleanupKeys = new Set([ - ...heartbeatLabelCleanup.labelKeys, - ...deletedSessionKeys, - ]); - const activityCleanupKeys = new Set([ - ...heartbeatLabelCleanup.hiddenSessionKeys, - ...deletedSessionKeys, - ]); - let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY; - let replacedHiddenHeartbeatSession = false; - const hiddenCurrentSession = findHiddenOpenClawHeartbeatSession(nextSessionKey, normalizedSessions); - if (hiddenCurrentSession || heartbeatLabelCleanup.hiddenSessionKeys.has(nextSessionKey)) { - const prefix = getCanonicalPrefixFromSessionKey(nextSessionKey) - ?? getCanonicalPrefixFromSessions(sessions) - ?? DEFAULT_CANONICAL_PREFIX; - nextSessionKey = `${prefix}:session-${Date.now()}`; - replacedHiddenHeartbeatSession = true; - } - if (!nextSessionKey.startsWith('agent:')) { - const canonicalMatch = canonicalBySuffix.get(nextSessionKey); - if (canonicalMatch) { - nextSessionKey = canonicalMatch; - } - } - if (!replacedHiddenHeartbeatSession && !visibleMergedSessions.find((s) => s.key === nextSessionKey) && visibleMergedSessions.length > 0) { - // Preserve only locally-created pending sessions. On initial boot the - // default ghost key (`agent:main:main`) should yield to real history. - const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey); - if (!hasLocalPendingSession) { - const fallbackKey = pickStartupSessionFallback(nextSessionKey, visibleMergedSessions); - if (fallbackKey) { - nextSessionKey = fallbackKey; - } - } - } - - const localCurrentSession = localSessions.find((session) => session.key === nextSessionKey); - const sessionsWithCurrent = !visibleMergedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey - && isClawXDesktopSessionKey(nextSessionKey) - ? [ - ...visibleMergedSessions, - localCurrentSession ?? { - key: nextSessionKey, - displayName: nextSessionKey, - ...(replacedHiddenHeartbeatSession ? { createdLocally: true } : {}), - }, - ] - : visibleMergedSessions; - - const previousSessionKey = currentSessionKey; - if (previousSessionKey !== nextSessionKey) { - // Mirror switchSession: stop in-flight history polls and swap cached - // history/run state immediately. Without this, a background loadSessions - // can retarget currentSessionKey (e.g. to a cron heartbeat session) - // while messages[] still holds the prior conversation until - // chat.history returns — which looks like cross-session contamination. - clearHistoryPoll(); - set((state) => { - const switchPatch = buildSessionSwitchPatch(state, nextSessionKey); + const mergedSessions = dedupedSessions.map((session) => { + const localSession = localSessionByKey.get(session.key); + const workspacePath = session.workspacePath ?? localSession?.workspacePath; + const preserveLocalDraft = localSession?.createdLocally === true + && localDraftSessionKeys.has(session.key); + if (!preserveLocalDraft && workspacePath === session.workspacePath) return session; return { - ...switchPatch, - sessions: sessionsWithCurrent, - sessionLabels: omitRecordKeys( - switchPatch.sessionLabels ?? state.sessionLabels, - labelCleanupKeys, - ), - sessionLastActivity: mergeSessionActivity( - omitRecordKeys( - switchPatch.sessionLastActivity ?? state.sessionLastActivity, - activityCleanupKeys, - ), - sessionsWithCurrent, - ), + ...session, + ...(workspacePath ? { workspacePath } : {}), + ...(preserveLocalDraft ? { createdLocally: true } : {}), }; }); - } else { - set((state) => ({ - sessions: sessionsWithCurrent, - currentSessionKey: nextSessionKey, - currentAgentId: getAgentIdFromSessionKey(nextSessionKey), - sessionLabels: omitRecordKeys(state.sessionLabels, labelCleanupKeys), - sessionLastActivity: mergeSessionActivity( - omitRecordKeys(state.sessionLastActivity, activityCleanupKeys), - sessionsWithCurrent, - ), - })); - } - reconcileCurrentSessionIdleFromBackend(set, get, sessionsWithCurrent); - applySessionBackendLabels(set, sessionsWithCurrent); - // Background: fetch first user message for every non-main session to populate labels upfront. - // This uses the Host API local transcript summary route, not Gateway - // chat.history, so it can run immediately without starving the - // foreground history load during startup/restart. - const existingSessionLabels = get().sessionLabels; - const existingSessionActivity = get().sessionLastActivity; - const sessionsToLabel = sessionsWithCurrent - .map((session) => ({ - session, - candidate: getSessionLabelHydrationCandidate( + if (previousGatewayListKeys) { + const currentKeys = new Set(localSessions.map((session) => session.key)); + for (const sessionKey of previousGatewayListKeys) { + if (!currentKeys.has(sessionKey)) suppressedSessionKeys.add(sessionKey); + } + } + previousGatewayListKeys = new Set(mergedSessions.map((session) => session.key)); + + const heartbeatCleanup = getHeartbeatCachedLabelCleanup(mergedSessions, currentSessionLabels); + for (const sessionKey of heartbeatCleanup.hiddenSessionKeys) { + clearSessionLabelHydrationTracking(sessionKey); + } + let visibleMergedSessions = heartbeatCleanup.hiddenSessionKeys.size > 0 + ? mergedSessions.filter((session) => !heartbeatCleanup.hiddenSessionKeys.has(session.key)) + : mergedSessions; + if (suppressedSessionKeys.size > 0) { + visibleMergedSessions = visibleMergedSessions.filter( + (session) => !suppressedSessionKeys.has(session.key), + ); + } + const pendingConfirmationSessions = localSessions.filter((session) => ( + pendingCatalogConfirmationSessionKeys.has(session.key) + && !normalizedSessionKeys.has(session.key) + )); + if (pendingConfirmationSessions.length > 0) { + visibleMergedSessions = [...visibleMergedSessions, ...pendingConfirmationSessions]; + } + + const listTs = typeof data.ts === 'number' && Number.isFinite(data.ts) ? data.ts : undefined; + const uncertainty = getSessionEventUncertainty(context.events); + const toOrderableAttentionRows = (rows: ChatSession[]): ChatSession[] => ( + uncertainty.keys.size === 0 + ? rows + : rows.filter((session) => !uncertainty.keys.has(session.key)) + ); + const attentionTransitions: SessionAttentionTransition[] = [ + { type: 'rows', rows: toOrderableAttentionRows(visibleMergedSessions) }, + ]; + const removedSessionKeys = new Set(); + const observedPendingConfirmationKeys = new Set( + [...pendingCatalogConfirmationSessionKeys].filter((key) => normalizedSessionKeys.has(key)), + ); + const attentionOrderIsReliable = listTs !== undefined && !uncertainty.unscoped; + + if (listTs === undefined) { + needsFollowUp = true; + } else { + successfulSessionListTsFloor = Math.max(successfulSessionListTsFloor ?? -Infinity, listTs); + for (const session of visibleMergedSessions) { + latestSessionEventTsByKey.set( + session.key, + Math.max(latestSessionEventTsByKey.get(session.key) ?? -Infinity, listTs), + ); + } + } + + for (const event of context.events) { + const eventTs = typeof event.ts === 'number' && Number.isFinite(event.ts) + ? event.ts + : undefined; + if (listTs === undefined || eventTs === undefined) { + needsFollowUp = true; + continue; + } + if (eventTs < listTs) continue; + + const sessionsBeforeEvent = visibleMergedSessions; + const result = applyGatewaySessionsChanged( + visibleMergedSessions, + event, + latestSessionEventTsByKey, + ); + const eventRemovedSessionKeys = getRemovedSessionKeys( + sessionsBeforeEvent, + result.sessions, + ); + if (result.deletedKey) eventRemovedSessionKeys.add(result.deletedKey); + if (result.applied) { + visibleMergedSessions = result.sessions; + const eventKey = getSessionsChangedExactKey(event); + if (eventKey && pendingCatalogConfirmationSessionKeys.has(eventKey)) { + observedPendingConfirmationKeys.add(eventKey); + } + if (eventRemovedSessionKeys.size > 0) { + for (const removedKey of eventRemovedSessionKeys) { + clearSessionLabelHydrationTracking(removedKey); + if (!uncertainty.keys.has(removedKey)) { + attentionTransitions.push({ type: 'delete', sessionKey: removedKey }); + } + } + } else { + attentionTransitions.push({ + type: 'rows', + rows: toOrderableAttentionRows(visibleMergedSessions), + }); + } + } + for (const removedKey of eventRemovedSessionKeys) removedSessionKeys.add(removedKey); + if (result.requiresReload) needsFollowUp = true; + } + + const labelCleanupKeys = new Set([ + ...heartbeatCleanup.labelKeys, + ...removedSessionKeys, + ]); + const activityCleanupKeys = new Set([ + ...heartbeatCleanup.hiddenSessionKeys, + ...removedSessionKeys, + ]); + + let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY; + let replacedHiddenHeartbeatSession = false; + const hiddenCurrentSession = findHiddenOpenClawHeartbeatSession( + nextSessionKey, + normalizedSessions, + ); + if (hiddenCurrentSession || heartbeatCleanup.hiddenSessionKeys.has(nextSessionKey)) { + const prefix = getCanonicalPrefixFromSessionKey(nextSessionKey) + ?? getCanonicalPrefixFromSessions(sessions) + ?? DEFAULT_CANONICAL_PREFIX; + nextSessionKey = `${prefix}:session-${Date.now()}`; + replacedHiddenHeartbeatSession = true; + } + if (!nextSessionKey.startsWith('agent:')) { + const canonicalMatch = canonicalBySuffix.get(nextSessionKey); + if (canonicalMatch) nextSessionKey = canonicalMatch; + } + if (removedSessionKeys.has(nextSessionKey)) { + const selection = repairMissingCurrentSelection(nextSessionKey, visibleMergedSessions); + visibleMergedSessions = selection.sessions; + nextSessionKey = selection.currentSessionKey; + } + if ( + !replacedHiddenHeartbeatSession + && !visibleMergedSessions.some((session) => session.key === nextSessionKey) + && visibleMergedSessions.length > 0 + ) { + const hasLocalPendingSession = localSessions.some( + (session) => session.key === nextSessionKey && session.createdLocally, + ); + if (!hasLocalPendingSession) { + const fallbackKey = pickStartupSessionFallback(nextSessionKey, visibleMergedSessions); + if (fallbackKey) nextSessionKey = fallbackKey; + } + } + + const localCurrentSession = localSessions.find((session) => session.key === nextSessionKey); + const currentSessionToInsert = localCurrentSession + && ( + localCurrentSession.createdLocally + || pendingCatalogConfirmationSessionKeys.has(nextSessionKey) + ) + ? localCurrentSession + : { + ...(localCurrentSession ?? { + key: nextSessionKey, + displayName: nextSessionKey, + }), + createdLocally: true, + }; + let sessionsWithCurrent = !visibleMergedSessions.some((session) => session.key === nextSessionKey) + && nextSessionKey + && isClawXDesktopSessionKey(nextSessionKey) + ? [ + ...visibleMergedSessions, + currentSessionToInsert, + ] + : visibleMergedSessions; + + const selectedSession = sessionsWithCurrent.find( + (session) => session.key === nextSessionKey, + ); + const hydrationCandidates = sessionsWithCurrent + .map((session) => ({ session, - existingSessionLabels, - existingSessionActivity, - { includeWorkspacePath: true }, - ), - })) - .filter((entry) => entry.candidate != null) - .map((entry) => ({ - session: entry.session, - version: entry.candidate!.version, - })); - if (sessionsToLabel.length > 0) { - void (async () => { - let pending = sessionsToLabel.filter(({ session, version }) => beginSessionLabelHydration(session.key, version)); + candidate: getSessionLabelHydrationCandidate( + session, + currentSessionLabels, + currentSessionActivity, + { includeWorkspacePath: true }, + ), + })) + .filter((entry) => entry.candidate != null) + .map((entry) => ({ session: entry.session, version: entry.candidate!.version })); + const selectedCandidate = selectedSession && !selectedSession.createdLocally + && !selectedSession.workspacePath?.trim() + ? hydrationCandidates.find(({ session }) => session.key === selectedSession.key) + : undefined; + const processedEventCount = context.events.length; + let selectedSummary: SessionLabelSummary | undefined; + let awaitedSummaries: SessionLabelSummary[] | null = null; + let awaitedHydrationFailed = false; + let awaitedHydrations: typeof hydrationCandidates = []; + + if (selectedSession && selectedCandidate) { + awaitedHydrations = hydrationCandidates.filter(({ session, version }) => ( + beginSessionLabelHydration(session.key, version) + )); for (let attempt = 0; attempt <= LABEL_FETCH_RETRY_DELAYS_MS.length; attempt += 1) { try { const summaries = await fetchSessionLabelSummaries( - pending.map(({ session }) => session.key), + awaitedHydrations.map(({ session }) => session.key), ); - applySessionLabelSummaries( - set, - summaries, - new Map(pending.map(({ session, version }) => [session.key, version])), - ); - const summaryBySessionKey = new Map( - summaries.map((summary) => [summary.sessionKey, summary]), - ); - - for (const { session, version } of pending) { - const summary = summaryBySessionKey.get(session.key); - const labelText = toAutomaticSessionLabel(summary?.firstUserText || ''); - finishSessionLabelHydration(session.key, version, labelText ? 'labeled' : 'empty'); - } - break; - } catch (err) { - const retryableStartup = classifyHistoryStartupRetryError(err) === 'gateway_startup'; - for (const { session, version } of pending) { - if (retryableStartup) { + if (generation !== sessionCatalogGeneration) { + for (const { session, version } of awaitedHydrations) { abandonSessionLabelHydration(session.key, version); - } else { - finishSessionLabelHydration(session.key, version, 'error'); } - } - if (!retryableStartup || attempt >= LABEL_FETCH_RETRY_DELAYS_MS.length) { break; } - await sleep(LABEL_FETCH_RETRY_DELAYS_MS[attempt]!); - pending = pending.filter(({ session, version }) => beginSessionLabelHydration(session.key, version)); - if (pending.length === 0) break; + awaitedSummaries = summaries; + selectedSummary = summaries.find( + (summary) => summary.sessionKey === selectedSession.key, + ); + break; + } catch (error) { + const retryableStartup = isGatewayStartupError(error); + if (!retryableStartup || attempt >= LABEL_FETCH_RETRY_DELAYS_MS.length) { + awaitedHydrationFailed = true; + break; + } + for (const { session, version } of awaitedHydrations) { + abandonSessionLabelHydration(session.key, version); + } + await delay(LABEL_FETCH_RETRY_DELAYS_MS[attempt]!); + if (generation !== sessionCatalogGeneration) break; + awaitedHydrations = hydrationCandidates.filter(({ session, version }) => ( + beginSessionLabelHydration(session.key, version) + )); + if (awaitedHydrations.length === 0) break; } } - })(); - } + } - if (previousSessionKey !== nextSessionKey) { - void get().loadHistory(); + const lateEvents = context.events.slice(processedEventCount); + const localCatalogChanged = localSessionCatalogRevision !== localRevisionBeforeRequest; + const canPublish = generation === sessionCatalogGeneration + && lateEvents.length === 0 + && !localCatalogChanged; + if (!canPublish) { + for (const { session, version } of awaitedHydrations) { + abandonSessionLabelHydration(session.key, version); + } + } + if (localCatalogChanged && generation === sessionCatalogGeneration) { + queuedForcedSessionGeneration = Math.max( + queuedForcedSessionGeneration ?? generation, + generation, + ); + } + if (lateEvents.length > 0 && generation === sessionCatalogGeneration) { + const pendingEvents = pendingGenerationSessionEvents?.generation === generation + ? pendingGenerationSessionEvents.events + : []; + pendingGenerationSessionEvents = { + generation, + events: [...pendingEvents, ...lateEvents], + }; + needsFollowUp = true; + } + + if (canPublish) { + for (const key of observedPendingConfirmationKeys) { + pendingCatalogConfirmationSessionKeys.delete(key); + } + if (selectedSummary && isHeartbeatOnlySummaryForSession(selectedSession, selectedSummary)) { + sessionsWithCurrent = sessionsWithCurrent.filter( + (session) => session.key !== selectedSummary!.sessionKey, + ); + labelCleanupKeys.add(selectedSummary.sessionKey); + activityCleanupKeys.add(selectedSummary.sessionKey); + attentionTransitions.push({ + type: 'delete', + sessionKey: selectedSummary.sessionKey, + }); + const selection = repairMissingCurrentSelection(nextSessionKey, sessionsWithCurrent); + sessionsWithCurrent = selection.sessions; + nextSessionKey = selection.currentSessionKey; + } else if (selectedSummary?.workspacePath?.trim()) { + const workspacePath = selectedSummary.workspacePath.trim(); + sessionsWithCurrent = sessionsWithCurrent.map((session) => ( + session.key === selectedSummary!.sessionKey + ? { ...session, workspacePath } + : session + )); + } + + if (attentionOrderIsReliable) { + useSessionAttentionStore.getState().reconcileSessionTransitions(attentionTransitions); + } + set((state) => { + const selectedCatalogSession = selectedSummary + ? sessionsWithCurrent.find((session) => session.key === selectedSummary.sessionKey) + : undefined; + let sessionLabels = omitRecordKeys(state.sessionLabels, labelCleanupKeys); + let sessionLastActivity = mergeSessionActivity( + omitRecordKeys(state.sessionLastActivity, activityCleanupKeys), + sessionsWithCurrent, + ); + const summaryLabel = selectedSummary && selectedCatalogSession + ? toAutomaticSessionLabel(selectedSummary.firstUserText || '') + : ''; + if ( + summaryLabel + && !sessionLabels[selectedSummary!.sessionKey] + && !hasExplicitSessionLabel(selectedCatalogSession) + ) { + sessionLabels = { ...sessionLabels, [selectedSummary!.sessionKey]: summaryLabel }; + } + if ( + selectedSummary + && selectedCatalogSession + && typeof selectedSummary.lastTimestamp === 'number' + && Number.isFinite(selectedSummary.lastTimestamp) + ) { + sessionLastActivity = { + ...sessionLastActivity, + [selectedSummary.sessionKey]: selectedSummary.lastTimestamp, + }; + } + return { + sessions: sessionsWithCurrent, + currentSessionKey: nextSessionKey, + currentAgentId: getAgentIdFromSessionKey(nextSessionKey), + sessionLabels, + sessionLastActivity, + }; + }); + applySessionBackendLabels(set, sessionsWithCurrent); + if (awaitedSummaries) { + applySessionLabelSummaries( + set, + awaitedSummaries, + new Map(awaitedHydrations.map( + ({ session, version }) => [session.key, version], + )), + ); + } + for (const { session, version } of awaitedHydrations) { + const summary = awaitedSummaries?.find( + (entry) => entry.sessionKey === session.key, + ); + const labelText = toAutomaticSessionLabel(summary?.firstUserText || ''); + finishSessionLabelHydration( + session.key, + version, + awaitedHydrationFailed ? 'error' : labelText ? 'labeled' : 'empty', + ); + } + + const existingSessionLabels = get().sessionLabels; + const existingSessionActivity = get().sessionLastActivity; + const sessionsToLabel = sessionsWithCurrent + .map((session) => ({ + session, + candidate: getSessionLabelHydrationCandidate( + session, + existingSessionLabels, + existingSessionActivity, + { includeWorkspacePath: true }, + ), + })) + .filter((entry) => entry.candidate != null) + .map((entry) => ({ session: entry.session, version: entry.candidate!.version })); + + if (sessionsToLabel.length > 0) { + void (async () => { + let pending = sessionsToLabel.filter(({ session, version }) => ( + beginSessionLabelHydration(session.key, version) + )); + for (let attempt = 0; attempt <= LABEL_FETCH_RETRY_DELAYS_MS.length; attempt += 1) { + try { + const summaries = await fetchSessionLabelSummaries( + pending.map(({ session }) => session.key), + ); + applySessionLabelSummaries( + set, + summaries, + new Map(pending.map(({ session, version }) => [session.key, version])), + ); + const summaryBySessionKey = new Map( + summaries.map((summary) => [summary.sessionKey, summary]), + ); + for (const { session, version } of pending) { + const summary = summaryBySessionKey.get(session.key); + const labelText = toAutomaticSessionLabel(summary?.firstUserText || ''); + finishSessionLabelHydration(session.key, version, labelText ? 'labeled' : 'empty'); + } + break; + } catch (error) { + const retryableStartup = isGatewayStartupError(error); + for (const { session, version } of pending) { + if (retryableStartup) abandonSessionLabelHydration(session.key, version); + else finishSessionLabelHydration(session.key, version, 'error'); + } + if (!retryableStartup || attempt >= LABEL_FETCH_RETRY_DELAYS_MS.length) break; + await delay(LABEL_FETCH_RETRY_DELAYS_MS[attempt]!); + pending = pending.filter(({ session, version }) => ( + beginSessionLabelHydration(session.key, version) + )); + if (pending.length === 0) break; + } + } + })(); + } + } } - } - } catch (err) { - console.warn('Failed to load sessions:', err); - if (generation === _sessionCatalogGeneration) { + } catch (error) { + console.warn('Failed to load sessions:', error); + if (generation === sessionCatalogGeneration) { let reducedSessions = get().sessions; const uncertainty = getSessionEventUncertainty(context.events); const toOrderableAttentionRows = (rows: ChatSession[]): ChatSession[] => ( @@ -3218,23 +1011,37 @@ export const useChatStore = create((set, get) => ({ : rows.filter((session) => !uncertainty.keys.has(session.key)) ); const attentionTransitions: SessionAttentionTransition[] = []; - const deletedSessionKeys = new Set(); + const removedSessionKeys = new Set(); + const observedPendingConfirmationKeys = new Set(); let appliedAny = false; + for (const event of context.events) { if (typeof event.ts !== 'number' || !Number.isFinite(event.ts)) continue; - if (_successfulSessionListTsFloor !== null && event.ts < _successfulSessionListTsFloor) continue; + if (successfulSessionListTsFloor !== null && event.ts < successfulSessionListTsFloor) continue; + const sessionsBeforeEvent = reducedSessions; const result = applyGatewaySessionsChanged( reducedSessions, event, - _latestSessionEventTsByKey, + latestSessionEventTsByKey, ); + const eventRemovedSessionKeys = getRemovedSessionKeys( + sessionsBeforeEvent, + result.sessions, + ); + if (result.deletedKey) eventRemovedSessionKeys.add(result.deletedKey); if (result.applied) { appliedAny = true; reducedSessions = result.sessions; - if (result.deletedKey) { - clearSessionLabelHydrationTracking(result.deletedKey); - if (!uncertainty.keys.has(result.deletedKey)) { - attentionTransitions.push({ type: 'delete', sessionKey: result.deletedKey }); + const eventKey = getSessionsChangedExactKey(event); + if (eventKey && pendingCatalogConfirmationSessionKeys.has(eventKey)) { + observedPendingConfirmationKeys.add(eventKey); + } + if (eventRemovedSessionKeys.size > 0) { + for (const removedKey of eventRemovedSessionKeys) { + clearSessionLabelHydrationTracking(removedKey); + if (!uncertainty.keys.has(removedKey)) { + attentionTransitions.push({ type: 'delete', sessionKey: removedKey }); + } } } else { attentionTransitions.push({ @@ -3243,235 +1050,263 @@ export const useChatStore = create((set, get) => ({ }); } } - if (result.deletedKey) deletedSessionKeys.add(result.deletedKey); + for (const removedKey of eventRemovedSessionKeys) removedSessionKeys.add(removedKey); } if (appliedAny) { + for (const key of observedPendingConfirmationKeys) { + pendingCatalogConfirmationSessionKeys.delete(key); + } if (!uncertainty.unscoped && attentionTransitions.length > 0) { useSessionAttentionStore.getState().reconcileSessionTransitions(attentionTransitions); } set((state) => { let sessionLabels = state.sessionLabels; let sessionLastActivity = state.sessionLastActivity; - for (const deletedKey of deletedSessionKeys) { - sessionLabels = clearSessionEntryFromMap(sessionLabels, deletedKey); - sessionLastActivity = clearSessionEntryFromMap(sessionLastActivity, deletedKey); + for (const removedKey of removedSessionKeys) { + sessionLabels = clearSessionEntryFromMap(sessionLabels, removedKey); + sessionLastActivity = clearSessionEntryFromMap(sessionLastActivity, removedKey); } + const selection = repairMissingCurrentSelection( + state.currentSessionKey, + reducedSessions, + ); return { - sessions: reducedSessions, + sessions: selection.sessions, sessionLabels, - sessionLastActivity: mergeSessionActivity(sessionLastActivity, reducedSessions), + sessionLastActivity: mergeSessionActivity(sessionLastActivity, selection.sessions), + currentSessionKey: selection.currentSessionKey, + currentAgentId: selection.currentAgentId, }; }); - reconcileCurrentSessionIdleFromBackend(set, get, reducedSessions); - applySessionBackendLabels(set, reducedSessions); + applySessionBackendLabels(set, get().sessions); } needsFollowUp = true; } } finally { - if (_loadSessionsContext === context) _loadSessionsContext = null; - _lastLoadSessionsAt = Date.now(); + if (loadSessionsContext === context) loadSessionsContext = null; + lastLoadSessionsAt = Date.now(); } - const queuedGeneration = _queuedForcedSessionGeneration; + const queuedGeneration = queuedForcedSessionGeneration; if (queuedGeneration !== null) { - _queuedForcedSessionGeneration = null; - generation = queuedGeneration; + queuedForcedSessionGeneration = null; + advanceGeneration(queuedGeneration); internalFollowUpUsed = false; continue; } if (needsFollowUp && !internalFollowUpUsed) { internalFollowUpUsed = true; - generation = _sessionCatalogGeneration; + advanceGeneration(sessionCatalogGeneration); continue; } break; } }; - let activeFlight = runFlight(_sessionCatalogGeneration); - _loadSessionsInFlight = activeFlight; + let activeFlight = runFlight(sessionCatalogGeneration); + loadSessionsInFlight = activeFlight; try { while (true) { await activeFlight; - const queuedGeneration = _queuedForcedSessionGeneration; + const queuedGeneration = queuedForcedSessionGeneration; if (queuedGeneration === null) break; - _queuedForcedSessionGeneration = null; - if (queuedGeneration < _sessionCatalogGeneration) continue; + queuedForcedSessionGeneration = null; + if (queuedGeneration < sessionCatalogGeneration) continue; activeFlight = runFlight(queuedGeneration); - _loadSessionsInFlight = activeFlight; + loadSessionsInFlight = activeFlight; } } finally { - if (_loadSessionsInFlight === activeFlight) { - _loadSessionsInFlight = null; - } + if (loadSessionsInFlight === activeFlight) loadSessionsInFlight = null; } }, handleSessionsChanged: (payload) => { - if (_loadSessionsContext?.generation === _sessionCatalogGeneration) { - _loadSessionsContext.events.push(payload); + if (loadSessionsContext?.generation === sessionCatalogGeneration) { + loadSessionsContext.events.push(payload); return; } - if (_pendingGenerationSessionEvents?.generation === _sessionCatalogGeneration) { - _pendingGenerationSessionEvents.events.push(payload); + if (pendingGenerationSessionEvents?.generation === sessionCatalogGeneration) { + pendingGenerationSessionEvents.events.push(payload); return; } const requestForcedReload = (): void => { - if (!_standaloneForcedSessionReloadPending) { - _standaloneForcedSessionReloadPending = true; - void get().loadSessions({ - force: true, - gatewayGeneration: _sessionCatalogGeneration, - }).finally(() => { - _standaloneForcedSessionReloadPending = false; - }); - } + if (standaloneForcedSessionReloadPending) return; + standaloneForcedSessionReloadPending = true; + void get().loadSessions({ + force: true, + gatewayGeneration: sessionCatalogGeneration, + }).finally(() => { + standaloneForcedSessionReloadPending = false; + }); }; if (typeof payload.ts !== 'number' || !Number.isFinite(payload.ts)) { requestForcedReload(); return; } - if (_successfulSessionListTsFloor !== null && payload.ts < _successfulSessionListTsFloor) { - return; - } + if (successfulSessionListTsFloor !== null && payload.ts < successfulSessionListTsFloor) return; + const sessionsBeforeEvent = get().sessions; const result = applyGatewaySessionsChanged( - get().sessions, + sessionsBeforeEvent, payload, - _latestSessionEventTsByKey, + latestSessionEventTsByKey, ); if (result.applied) { + const removedSessionKeys = getRemovedSessionKeys(sessionsBeforeEvent, result.sessions); + if (result.deletedKey) removedSessionKeys.add(result.deletedKey); const eventKey = typeof payload.session?.key === 'string' ? payload.session.key.trim() - : typeof payload.sessionKey === 'string' - ? payload.sessionKey.trim() - : typeof payload.key === 'string' ? payload.key.trim() : ''; + : typeof payload.sessionKey === 'string' + ? payload.sessionKey.trim() + : typeof payload.key === 'string' ? payload.key.trim() : ''; + if (eventKey) pendingCatalogConfirmationSessionKeys.delete(eventKey); const affectedRow = result.sessions.find((session) => session.key === eventKey); - if (affectedRow) { - useSessionAttentionStore.getState().reconcileSessionRows([affectedRow]); + if (affectedRow) useSessionAttentionStore.getState().reconcileSessionRows([affectedRow]); + for (const removedKey of removedSessionKeys) { + clearSessionLabelHydrationTracking(removedKey); + useSessionAttentionStore.getState().removeSession(removedKey); } - set((state) => ({ - sessions: result.sessions, - sessionLabels: result.deletedKey - ? clearSessionEntryFromMap(state.sessionLabels, result.deletedKey) - : state.sessionLabels, - sessionLastActivity: result.deletedKey - ? clearSessionEntryFromMap(state.sessionLastActivity, result.deletedKey) - : mergeSessionActivity(state.sessionLastActivity, result.sessions), - })); - reconcileCurrentSessionIdleFromBackend(set, get, result.sessions); - applySessionBackendLabels(set, result.sessions); - } - if (result.deletedKey) { - clearSessionLabelHydrationTracking(result.deletedKey); - useSessionAttentionStore.getState().removeSession(result.deletedKey); - } - if (result.requiresReload) { - requestForcedReload(); + set((state) => { + let sessionLabels = state.sessionLabels; + let sessionLastActivity = state.sessionLastActivity; + for (const removedKey of removedSessionKeys) { + sessionLabels = clearSessionEntryFromMap(sessionLabels, removedKey); + sessionLastActivity = clearSessionEntryFromMap(sessionLastActivity, removedKey); + } + const selection = repairMissingCurrentSelection(state.currentSessionKey, result.sessions); + return { + sessions: selection.sessions, + sessionLabels, + sessionLastActivity: mergeSessionActivity(sessionLastActivity, selection.sessions), + currentSessionKey: selection.currentSessionKey, + currentAgentId: selection.currentAgentId, + }; + }); + applySessionBackendLabels(set, get().sessions); } + if (result.requiresReload) requestForcedReload(); }, - // ── Switch session ── - - switchSession: (key: string) => { + switchSession: (key) => { if (key === get().currentSessionKey) return; - // Stop any background polling for the old session before switching. - // This prevents the poll timer from firing after the switch and loading - // the wrong session's history into the new session's view. - clearHistoryPoll(); - clearBaselines(); - set((s) => buildSessionSwitchPatch(s, key)); - get().loadHistory(); + markLocalSessionCatalogMutation(); + set((state) => buildSessionSwitchPatch(state, key)); }, - selectAcpSession: (key: string, workspacePath?: string) => { + selectAcpSession: (key, workspacePath) => { + const { currentSessionKey, sessions } = get(); if ( - key === get().currentSessionKey - && (!workspacePath || get().sessions.find((session) => session.key === key)?.workspacePath === workspacePath) + key === currentSessionKey + && (!workspacePath || sessions.find((session) => session.key === key)?.workspacePath === workspacePath) ) return; - clearHistoryPoll(); - clearBaselines(); - set((s) => buildSessionSwitchPatch(s, key, { workspacePath })); + if (sessions.some((session) => session.key === currentSessionKey && session.createdLocally)) { + localDraftSessionKeys.delete(currentSessionKey); + } + if (!sessions.some((session) => session.key === key)) localDraftSessionKeys.add(key); + markLocalSessionCatalogMutation(); + set((state) => buildSessionSwitchPatch(state, key, { createdLocally: true, workspacePath })); }, - // ── Delete session ── - // - // NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent) - // RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase. - // Deletion is therefore performed locally: the renderer drops the session - // from the sidebar / labels / activity maps and the Main process hard-deletes - // the on-disk transcript so it stops appearing in sessions.list and stops - // contributing to the Dashboard token-usage history. + newSession: () => { + const { currentSessionKey, sessions } = get(); + const prefix = getCanonicalPrefixFromSessionKey(currentSessionKey) + ?? getCanonicalPrefixFromSessions(sessions) + ?? DEFAULT_CANONICAL_PREFIX; + const sessionKey = `${prefix}:session-${Date.now()}`; + if (sessions.some((session) => session.key === currentSessionKey && session.createdLocally)) { + localDraftSessionKeys.delete(currentSessionKey); + } + localDraftSessionKeys.add(sessionKey); + markLocalSessionCatalogMutation(); + set((state) => buildSessionSwitchPatch( + state, + sessionKey, + { createdLocally: true }, + )); + }, - deleteSession: async (key: string) => { - clearCachedSessionHistory(key); - clearCachedSessionRunState(key); - clearSessionLabelHydrationTracking(key); - clearPendingOptimisticUserMessages(key); - // Hard-delete the session's JSONL transcript on disk. - // The main process unlinks .jsonl plus any leftover - // .deleted.jsonl and .jsonl.reset.* siblings, then removes the - // entry from sessions.json so sessions.list stops surfacing it. - let hardDeleteSucceeded = false; + acknowledgeAcpSessionCreated: (key, workspacePath, initialPrompt) => { + const normalizedWorkspacePath = workspacePath?.trim(); + const initialLabel = key.endsWith(':main') ? '' : toSessionLabel(initialPrompt || ''); + localDraftSessionKeys.delete(key); + pendingCatalogConfirmationSessionKeys.add(key); + markLocalSessionCatalogMutation(); + set((state) => { + const sessionEntry = state.sessions.find((session) => session.key === key); + return { + sessions: sessionEntry + ? state.sessions.map((session) => ( + session.key === key && session.createdLocally + ? (() => { + const acknowledgedSession = { ...session }; + delete acknowledgedSession.createdLocally; + return { + ...acknowledgedSession, + ...(normalizedWorkspacePath ? { workspacePath: normalizedWorkspacePath } : {}), + }; + })() + : session + )) + : [...state.sessions, { + key, + displayName: key, + ...(normalizedWorkspacePath ? { workspacePath: normalizedWorkspacePath } : {}), + }], + ...(initialLabel && !hasExplicitSessionLabel(sessionEntry) && !state.sessionLabels[key] + ? { sessionLabels: { ...state.sessionLabels, [key]: initialLabel } } + : {}), + }; + }); + }, + + deleteSession: async (key) => { try { const result = await hostApi.sessions.delete(key); if (!result.success) { - console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error); - } else { - hardDeleteSucceeded = true; + const error = result.error || 'Failed to delete session'; + console.warn(`[deleteSession] API reported failure for ${key}:`, error); + return { success: false, error }; } - } catch (err) { - console.warn(`[deleteSession] IPC call failed for ${key}:`, err); - } - if (hardDeleteSucceeded) { - useSessionAttentionStore.getState().removeSession(key); + } catch (error) { + console.warn(`[deleteSession] API call failed for ${key}:`, error); + return { success: false, error: String(error) }; } - const { currentSessionKey, sessions } = get(); - const remaining = sessions.filter((s) => s.key !== key); + clearSessionLabelHydrationTracking(key); + localDraftSessionKeys.delete(key); + useSessionAttentionStore.getState().removeSession(key); + pendingCatalogConfirmationSessionKeys.delete(key); - if (currentSessionKey === key) { - // Switched away from deleted session — pick the first remaining or create new - const next = remaining[0]; - set((s) => ({ - sessions: remaining, - sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)), - sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)), - messages: [], - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - runError: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY, - currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY), - })); - if (next) { - get().loadHistory(); - } - } else { - set((s) => ({ - sessions: remaining, - sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)), - sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)), - })); - } + markLocalSessionCatalogMutation(); + set((state) => { + const remaining = state.sessions.filter((session) => session.key !== key); + const selection = state.currentSessionKey === key + ? repairMissingCurrentSelection(state.currentSessionKey, remaining) + : { + sessions: remaining, + currentSessionKey: state.currentSessionKey, + currentAgentId: state.currentAgentId, + }; + return { + sessions: selection.sessions, + sessionLabels: clearSessionEntryFromMap(state.sessionLabels, key), + sessionLastActivity: clearSessionEntryFromMap(state.sessionLastActivity, key), + currentSessionKey: selection.currentSessionKey, + currentAgentId: selection.currentAgentId, + }; + }); + return { success: true }; }, - deleteSessions: async (keys: string[]) => { + deleteSessions: async (keys) => { const requestedKeys = [...new Set(keys)].filter(Boolean); const deletedKeys: string[] = []; const failedKeys: string[] = []; - // Main rewrites each agent's sessions.json during deletion. Keep these - // operations sequential so two sessions for the same agent cannot race. + // Main rewrites each agent's sessions.json, so deletes must stay sequential. for (const key of requestedKeys) { try { const result = await hostApi.sessions.delete(key); @@ -3481,119 +1316,47 @@ export const useChatStore = create((set, get) => ({ failedKeys.push(key); } } - if (deletedKeys.length === 0) return { deletedKeys, failedKeys }; const deletedSet = new Set(deletedKeys); for (const key of deletedKeys) { - clearCachedSessionHistory(key); - clearCachedSessionRunState(key); clearSessionLabelHydrationTracking(key); - clearPendingOptimisticUserMessages(key); + localDraftSessionKeys.delete(key); + useSessionAttentionStore.getState().removeSession(key); + pendingCatalogConfirmationSessionKeys.delete(key); } - - const before = get(); - const remaining = before.sessions.filter((session) => !deletedSet.has(session.key)); - const currentWasDeleted = deletedSet.has(before.currentSessionKey); - const next = currentWasDeleted ? remaining[0] : undefined; - - set((state) => ({ - sessions: state.sessions.filter((session) => !deletedSet.has(session.key)), - sessionLabels: Object.fromEntries( - Object.entries(state.sessionLabels).filter(([key]) => !deletedSet.has(key)), - ), - sessionLastActivity: Object.fromEntries( - Object.entries(state.sessionLastActivity).filter(([key]) => !deletedSet.has(key)), - ), - ...(currentWasDeleted - ? { - messages: [], - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - runError: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY, - currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY), - } - : {}), - })); - - if (currentWasDeleted && next) await get().loadHistory(); + markLocalSessionCatalogMutation(); + set((state) => { + const remaining = state.sessions.filter((session) => !deletedSet.has(session.key)); + const currentWasDeleted = deletedSet.has(state.currentSessionKey); + const selection = currentWasDeleted + ? repairMissingCurrentSelection(state.currentSessionKey, remaining) + : { + sessions: remaining, + currentSessionKey: state.currentSessionKey, + currentAgentId: state.currentAgentId, + }; + return { + sessions: selection.sessions, + sessionLabels: Object.fromEntries( + Object.entries(state.sessionLabels).filter(([key]) => !deletedSet.has(key)), + ), + sessionLastActivity: Object.fromEntries( + Object.entries(state.sessionLastActivity).filter(([key]) => !deletedSet.has(key)), + ), + currentSessionKey: selection.currentSessionKey, + currentAgentId: selection.currentAgentId, + }; + }); return { deletedKeys, failedKeys }; }, - // ── New session ── - - newSession: () => { - // Generate a new unique session key and switch to it. - // NOTE: We intentionally do NOT call sessions.reset on the old session. - // sessions.reset archives (renames) the session JSONL file, making old - // conversation history inaccessible when the user switches back to it. - const { currentSessionKey, sessions } = get(); - const prefix = getCanonicalPrefixFromSessionKey(currentSessionKey) - ?? getCanonicalPrefixFromSessions(sessions) - ?? DEFAULT_CANONICAL_PREFIX; - const newKey = `${prefix}:session-${Date.now()}`; - - // Use the same switch patch as explicit sidebar switching so a running - // source session keeps its cached lifecycle. Without this, New Chat clears - // the active run globally; switching back to the still-running session then - // shows only the local transcript snapshot and loses the live execution UI. - clearHistoryPoll(); - clearBaselines(); - set((s) => buildSessionSwitchPatch(s, newKey, { createdLocally: true })); - }, - - acknowledgeAcpSessionCreated: (key: string, workspacePath?: string, initialPrompt?: string) => { - const normalizedWorkspacePath = workspacePath?.trim(); - const initialLabel = key.endsWith(':main') ? '' : toSessionLabel(initialPrompt || ''); - set((s) => { - const sessionEntry = s.sessions.find((session) => session.key === key); - return { - sessions: sessionEntry - ? s.sessions.map((session) => ( - session.key === key && session.createdLocally - ? { - ...session, - createdLocally: false, - ...(normalizedWorkspacePath ? { workspacePath: normalizedWorkspacePath } : {}), - } - : session - )) - : [...s.sessions, { - key, - displayName: key, - ...(normalizedWorkspacePath ? { workspacePath: normalizedWorkspacePath } : {}), - }], - ...(initialLabel && !hasExplicitSessionLabel(sessionEntry) && !s.sessionLabels[key] - ? { sessionLabels: { ...s.sessionLabels, [key]: initialLabel } } - : {}), - }; - }); - }, - - // ── Rename session ── - - renameSession: async (key: string, label: string) => { + renameSession: async (key, label) => { const normalized = label.trim(); - if (!normalized) { - throw new Error('Session label cannot be empty'); - } + if (!normalized) throw new Error('Session label cannot be empty'); - try { - const result = await hostApi.sessions.rename(key, normalized); - if (!result.success) { - throw new Error(result.error || 'Failed to rename session'); - } - } catch (err) { - console.error(`[renameSession] API call failed for ${key}:`, err); - throw err; - } + const result = await hostApi.sessions.rename(key, normalized); + if (!result.success) throw new Error(result.error || 'Failed to rename session'); const session = get().sessions.find((entry) => entry.key === key); if (session) { @@ -3603,1599 +1366,12 @@ export const useChatStore = create((set, get) => ({ 'backend-label', ); } - - set((s) => ({ - sessions: s.sessions.map((entry) => - entry.key === key ? { ...entry, label: normalized } : entry, - ), - sessionLabels: { ...s.sessionLabels, [key]: normalized }, + markLocalSessionCatalogMutation(); + set((state) => ({ + sessions: state.sessions.map((entry) => ( + entry.key === key ? { ...entry, label: normalized } : entry + )), + sessionLabels: { ...state.sessionLabels, [key]: normalized }, })); }, - - // ── Cleanup empty session on navigate away ── - - cleanupEmptySession: () => { - const { currentSessionKey, messages, sessions, sessionLastActivity, sessionLabels } = get(); - // Only remove non-main sessions that were never used (no messages sent). - // This mirrors the "leavingEmpty" logic in switchSession so that creating - // a new session and immediately navigating away doesn't leave a ghost entry - // in the sidebar. - // Also check sessionLastActivity and sessionLabels comprehensively to prevent - // falsely treating sessions with history as empty due to switchSession clearing messages early. - const isEmptyNonMain = !currentSessionKey.endsWith(':main') - && messages.length === 0 - && !sessionLastActivity[currentSessionKey] - && !sessionLabels[currentSessionKey] - && !hasExplicitSessionLabel(sessions.find((session) => session.key === currentSessionKey)); - if (!isEmptyNonMain) return; - set((s) => ({ - sessions: s.sessions.filter((sess) => sess.key !== currentSessionKey), - sessionLabels: Object.fromEntries( - Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey), - ), - sessionLastActivity: Object.fromEntries( - Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey), - ), - })); - }, - - // ── Load chat history ── - - loadHistory: async (quiet = false) => { - const { currentSessionKey } = get(); - const foregroundLoadKey = getHistoryForegroundLoadKey(currentSessionKey); - const isInitialForegroundLoad = !quiet && !_foregroundHistoryLoadSeen.has(foregroundLoadKey); - const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad); - const forceLoad = _forceNextHistoryLoadBySession.delete(currentSessionKey); - const existingLoad = _historyLoadInFlight.get(currentSessionKey); - const shouldShowForegroundLoading = !quiet && get().messages.length === 0; - if (existingLoad) { - await existingLoad; - if (!forceLoad) { - return; - } - if (get().currentSessionKey !== currentSessionKey) { - return; - } - } - - const lastLoadAt = _lastHistoryLoadAtBySession.get(currentSessionKey) || 0; - if (!forceLoad && quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) { - return; - } - - if (shouldShowForegroundLoading) set({ loading: true, error: null, runError: null }); - - // Safety guard: if history loading takes too long, force loading to false - // to prevent the UI from being stuck in a spinner forever. - let loadingTimedOut = false; - const loadingSafetyTimer = shouldShowForegroundLoading ? setTimeout(() => { - loadingTimedOut = true; - set({ loading: false }); - }, getHistoryLoadingSafetyTimeout(isInitialForegroundLoad)) : null; - - const loadPromise = (async () => { - const isCurrentSession = () => get().currentSessionKey === currentSessionKey; - const getPreviewMergeKey = (message: RawMessage): string => ( - `${message.id ?? ''}|${message.role}|${message.timestamp ?? ''}|${getMessageText(message.content)}` - ); - const mergeHydratedMessages = ( - currentMessages: RawMessage[], - hydratedMessages: RawMessage[], - ): RawMessage[] => { - const hydratedFilesByKey = new Map( - hydratedMessages - .filter((message) => message._attachedFiles?.length) - .map((message) => [ - getPreviewMergeKey(message), - message._attachedFiles!.map((file) => ({ ...file })), - ]), - ); - - return currentMessages.map((message) => { - const attachedFiles = hydratedFilesByKey.get(getPreviewMergeKey(message)); - return attachedFiles - ? { ...message, _attachedFiles: attachedFiles } - : message; - }); - }; - type AttachedFile = NonNullable[number]; - const getAttachmentMergeKey = (file: AttachedFile): string | null => ( - file.filePath || file.gatewayUrl || null - ); - const preserveExistingAttachmentPreviews = ( - currentMessages: RawMessage[], - nextMessages: RawMessage[], - ): RawMessage[] => { - const currentFilesByMessageKey = new Map>(); - for (const message of currentMessages) { - if (!message._attachedFiles?.length) continue; - const filesByKey = new Map(); - for (const file of message._attachedFiles) { - const key = getAttachmentMergeKey(file); - if (!key) continue; - if (!file.preview && !file.fileSize && !file.previewStatus) continue; - filesByKey.set(key, file); - } - if (filesByKey.size > 0) { - currentFilesByMessageKey.set(getPreviewMergeKey(message), filesByKey); - } - } - - if (currentFilesByMessageKey.size === 0) return nextMessages; - - return nextMessages.map((message) => { - if (!message._attachedFiles?.length) return message; - const currentFiles = currentFilesByMessageKey.get(getPreviewMergeKey(message)); - if (!currentFiles) return message; - - let changed = false; - const attachedFiles = message._attachedFiles.map((file) => { - const key = getAttachmentMergeKey(file); - const currentFile = key ? currentFiles.get(key) : undefined; - if (!currentFile) return file; - - let nextFile = file; - if (!nextFile.preview && currentFile.preview) { - nextFile = { ...nextFile, preview: currentFile.preview }; - changed = true; - } - if (!nextFile.fileSize && currentFile.fileSize) { - nextFile = { ...nextFile, fileSize: currentFile.fileSize }; - changed = true; - } - if (!nextFile.previewStatus && currentFile.previewStatus) { - nextFile = { ...nextFile, previewStatus: currentFile.previewStatus }; - changed = true; - } - return nextFile; - }); - - return changed ? { ...message, _attachedFiles: attachedFiles } : message; - }); - }; - - const applyLoadFailure = (errorMessage: string | null) => { - if (!isCurrentSession()) return; - set((state) => { - const mergedMessages = mergePendingOptimisticUserMessages(currentSessionKey, state.messages); - return { - loading: false, - error: shouldShowForegroundLoading && errorMessage ? errorMessage : state.error, - ...(mergedMessages.length > 0 ? { messages: mergedMessages } : { messages: [] as RawMessage[] }), - }; - }); - }; - - const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => { - // Guard: if the user switched sessions while this async load was in - // flight, discard the result to prevent overwriting the new session's - // messages with stale data from the old session. - if (!isCurrentSession()) return false; - - // Before filtering: attach images/files from tool_result messages to the next assistant message - const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); - const messagesWithToolAttachments = enrichWithToolCallAttachments(messagesWithToolImages); - const filteredMessages = messagesWithToolAttachments.filter((msg) => !shouldDropMessageFromHistory(msg)); - // Restore file attachments for user/assistant messages (from cache + text patterns) - const enrichedMessages = enrichWithCachedImages(filteredMessages); - - // Preserve optimistic user messages independently from sending state. - // Gateway phase=end can clear sending before chat.history has persisted - // the user turn; without this, an early quiet reload briefly removes it. - let finalMessages = mergePendingOptimisticUserMessages(currentSessionKey, enrichedMessages); - const userMsgAt = get().lastUserMessageAt; - if (get().sending && userMsgAt) { - const userMsMs = toMs(userMsgAt); - const optimistic = getLatestOptimisticUserMessage(get().messages, userMsMs); - const hasMatchingUser = optimistic - ? hasOptimisticServerEcho(finalMessages, optimistic, userMsMs) - : false; - if (optimistic && !hasMatchingUser) { - finalMessages = [...finalMessages, optimistic]; - } - } - finalMessages = dropRedundantOptimisticUserMessages(currentSessionKey, finalMessages); - finalMessages = preserveExistingAttachmentPreviews(get().messages, finalMessages); - - const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get(); - const userMsTs = lastUserMessageAt != null ? toMs(lastUserMessageAt) : 0; - const isAfterUserMsg = (msg: RawMessage): boolean => { - if (lastUserMessageAt == null) return true; - if (!msg.timestamp) return false; - return toMs(msg.timestamp) >= userMsTs; - }; - const isRealUserBoundary = (msg: RawMessage): boolean => { - if (msg.role !== 'user') return false; - if (!Array.isArray(msg.content)) return true; - const blocks = msg.content as Array<{ type?: string }>; - return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); - }; - const openRunSegment = isSendingNow && lastUserMessageAt != null - ? getOpenRunSegmentFromHistory(filteredMessages, lastUserMessageAt) - : postUserSegmentMessages(filteredMessages); - const postBoundaryMessages = isSendingNow && lastUserMessageAt != null - ? openRunSegment - : (lastUserMessageAt != null - ? filteredMessages.filter((msg) => isAfterUserMsg(msg)) - : (() => { - for (let i = filteredMessages.length - 1; i >= 0; i -= 1) { - if (isRealUserBoundary(filteredMessages[i])) { - return filteredMessages.slice(i + 1); - } - } - return filteredMessages; - })()); - const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant'); - const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary - && (getMessageStopReason(lastAssistantAfterBoundary) === 'error' - || isFailedAssistantTurnMessage(lastAssistantAfterBoundary)) - ? (getMessageErrorMessage(lastAssistantAfterBoundary) - ?? (isFailedAssistantTurnMessage(lastAssistantAfterBoundary) - ? getMessageText(lastAssistantAfterBoundary.content) - : null)) - : null; - const historyErrorIsTransient = Boolean( - latestTerminalAssistantErrorMessage - && isSendingNow - && isRecoverableRuntimeError(latestTerminalAssistantErrorMessage), - ); - - set({ - messages: finalMessages, - thinkingLevel, - loading: false, - runError: historyErrorIsTransient - ? null - : shouldShowRunError( - currentSessionKey, - latestTerminalAssistantErrorMessage, - get().dismissedRunErrors, - ), - }); - cacheSessionHistory(currentSessionKey, finalMessages, thinkingLevel); - - // Seed a missing label from immutable history only. Once a label exists - // for a session, do not rewrite it during later history refreshes; users - // perceive the sidebar title as a stable conversation identifier, not a - // live summary of the latest turn. - const isMainSession = currentSessionKey.endsWith(':main'); - const currentSession = get().sessions.find((session) => session.key === currentSessionKey); - if (!isMainSession && !hasExplicitSessionLabel(currentSession) && !get().sessionLabels[currentSessionKey]) { - const firstUserMsg = finalMessages.find((m) => m.role === 'user'); - if (firstUserMsg) { - const labelText = toAutomaticSessionLabel(getMessageText(firstUserMsg.content)); - if (labelText) { - set((s) => ( - s.sessionLabels[currentSessionKey] - ? {} - : { sessionLabels: { ...s.sessionLabels, [currentSessionKey]: labelText } } - )); - } - } - } - - // Record last activity time from the last message in history - const lastMsg = finalMessages[finalMessages.length - 1]; - if (lastMsg?.timestamp) { - const lastAt = toMs(lastMsg.timestamp); - set((s) => ({ - sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: lastAt }, - })); - } - - // Async: load missing image previews from disk (updates in background) - loadMissingPreviews(finalMessages).then((updated) => { - if (!isCurrentSession()) return; - if (updated) { - set((state) => ({ - messages: mergeHydratedMessages(state.messages, finalMessages), - })); - } - }); - - if (latestTerminalAssistantErrorMessage && !historyErrorIsTransient) { - clearHistoryPoll(); - set({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - }); - return true; - } - - // History poll is the fallback when Gateway streaming events are missing - // (WS disconnect, console-only runs, etc.). Any assistant turn after the - // user's message counts as progress so the safety timeout does not emit a - // false "No response received" error while tool chains are still running. - const progressSegment = openRunSegment; - if (isSendingNow && segmentHasMeaningfulAssistantProgress(progressSegment)) { - _lastChatEventAt = Date.now(); - if (get().error || get().runError) { - set({ error: null, runError: null }); - } - } - - // Promote pendingFinal only when there's a *final-looking* assistant - // message after the user — i.e. one that has actual user-visible output - // (text/image) AND is not still waiting on a tool result. This used to - // promote on *any* assistant message after the user, which fired on the - // very first `[thinking, toolCall]` intermediate turn and then paired - // with the closer below to clobber the entire run state. - if (isSendingNow && !pendingFinal) { - const hasFinalLikeAssistant = openRunSegment.some((msg) => { - if (msg.role !== 'assistant') return false; - if (hasPendingToolUse(msg)) return false; - return hasNonToolAssistantContent(msg); - }); - if (hasFinalLikeAssistant) { - set({ pendingFinal: true }); - } - } - - // If pendingFinal, check whether the AI produced a final text response. - // CRITICAL: reject intermediate tool turns (thinking+tool_use, mixed - // thinking+text+tool_use, etc.) so the run stays "open" across all tool - // rounds. Without `hasPendingToolUse` the closer matches the first - // `[thinking, toolCall]` intermediate turn (because thinking *used to* - // count as non-tool content), clears `sending` / `activeRunId` / - // `pendingFinal`, and makes the Thinking… indicator vanish mid-chain. - if (pendingFinal || get().pendingFinal) { - const recentAssistant = [...openRunSegment].reverse().find((msg) => { - if (msg.role !== 'assistant') return false; - if (hasPendingToolUse(msg)) return false; - return hasNonToolAssistantContent(msg); - }); - if (recentAssistant) { - clearHistoryPoll(); - set({ sending: false, activeRunId: null, pendingFinal: false, runError: null }); - captureSessionRunState(currentSessionKey, DEFAULT_SESSION_RUN_STATE); - } - } - - // Unstick lifecycle when history already has a conclusive reply but the - // Gateway never emitted a terminal phase event (WS drop, console run, etc.). - // Allow unsticking when streamingTools is empty OR all entries are completed - // (completed tool entries linger after tool rounds and must not block this). - const noRunningTools = !get().streamingTools.some((t) => t.status === 'running'); - if (isSendingNow && !get().streamingMessage && noRunningTools) { - const openSegment = openRunSegment; - const hasConclusiveReply = openSegment.some((message) => { - if (message.role !== 'assistant') return false; - if (hasPendingToolUse(message)) return false; - return hasNonToolAssistantContent(message); - }); - const hasDeliveredImageReply = openSegment.some((message) => message.role === 'assistant' && messageHasImageContent(message)); - if (hasDeliveredImageReply && !segmentHasOpenToolRun(openSegment)) { - clearHistoryPoll(); - set({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - runError: null, - streamingMessage: null, - streamingText: '', - streamingTools: [], - pendingToolImages: [], - }); - captureSessionRunState(currentSessionKey, DEFAULT_SESSION_RUN_STATE); - } else if (hasConclusiveReply && !segmentHasOpenToolRun(openSegment)) { - clearHistoryPoll(); - set({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - runError: null, - }); - captureSessionRunState(currentSessionKey, DEFAULT_SESSION_RUN_STATE); - } - // Also unstick when all tool calls are resolved but the model's - // terminal response was thinking-only (no visible content). The - // `segmentHasOpenToolRun` update above detects this, but we still - // need an explicit conclusive-reply fallback for the case where - // hasConclusiveReply is false (thinking-only terminal turn). - if (!hasConclusiveReply && !segmentHasOpenToolRun(openSegment) && openSegment.length > 0) { - clearHistoryPoll(); - set({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - runError: null, - }); - captureSessionRunState(currentSessionKey, DEFAULT_SESSION_RUN_STATE); - } - } - - // After session switch the renderer may have reset run lifecycle flags even - // though the Gateway is still executing a user-initiated turn. Re-arm only - // when this session had an active cached run (e.g. user switched away - // mid-send). Do not re-arm from stale :main heartbeat/tool history alone. - if (!get().sending && !latestTerminalAssistantErrorMessage && hasCachedActiveUserRun(currentSessionKey)) { - const openSegment = postUserSegmentMessages(filteredMessages); - if (segmentHasOpenToolRun(openSegment)) { - const lastUser = findLastRealUserMessage(filteredMessages); - const inferredUserAt = lastUser?.timestamp ? toMs(lastUser.timestamp) : Date.now(); - set({ - sending: true, - pendingFinal: true, - lastUserMessageAt: inferredUserAt, - }); - captureSessionRunState(currentSessionKey, get()); - } - } - - if ( - get().sending - && !latestTerminalAssistantErrorMessage - && !shouldTrackInboundRunLifecycle(get(), currentSessionKey) - ) { - clearHistoryPoll(); - set({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - }); - } - return true; - }; - - let localFallbackApplied = false; - let gatewayHistorySettled = false; - - const applyLocalFallbackMessages = async ( - options: { onlyWhileGatewayPending?: boolean; logTimeout?: boolean } = {}, - ): Promise => { - const fallbackMessages = await loadLocalHistoryFallback(currentSessionKey, 200, { - logTimeout: options.logTimeout, - }); - if ( - fallbackMessages.length === 0 - || !isCurrentSession() - || (options.onlyWhileGatewayPending && gatewayHistorySettled) - ) { - return false; - } - - const applied = applyLoadedMessages(fallbackMessages, null); - if (!applied) return false; - - localFallbackApplied = true; - set({ hasMoreHistory: fallbackMessages.length >= HISTORY_PAGE_SIZE }); - if (isInitialForegroundLoad) { - _foregroundHistoryLoadSeen.add(foregroundLoadKey); - void refreshVisibleSessionSummaries(set, get); - } - return true; - }; - - const applyStartupFallbackAfterGrace = async (): Promise<'fallback' | 'none'> => { - if (!isInitialForegroundLoad || !shouldShowForegroundLoading) { - return 'none'; - } - await sleep(CHAT_HISTORY_STARTUP_FALLBACK_RACE_MS); - if (!isCurrentSession() || gatewayHistorySettled) { - return 'none'; - } - const applied = await applyLocalFallbackMessages({ - onlyWhileGatewayPending: true, - logTimeout: false, - }); - return applied ? 'fallback' : 'none'; - }; - - const loadGatewayHistory = async (): Promise => { - try { - const fallbackMessages: RawMessage[] = []; - const chatHistoryParams = buildChatHistoryRpcParams( - currentSessionKey, - HISTORY_PAGE_SIZE, - getChatHistoryMaxChars(), - ); - - let data: Record | null = null; - let lastError: unknown = null; - - for (let attempt = 0; attempt <= CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length; attempt += 1) { - if (!isCurrentSession()) { - break; - } - - try { - data = await fetchChatHistory( - currentSessionKey, - HISTORY_PAGE_SIZE, - chatHistoryParams.maxChars, - historyTimeoutOverride, - ); - lastError = null; - break; - } catch (error) { - lastError = error; - } - - if (!isCurrentSession()) { - break; - } - - const errorKind = classifyHistoryStartupRetryError(lastError); - const shouldRetry = isInitialForegroundLoad - && attempt < CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length - && shouldRetryStartupHistoryLoad(useGatewayStore.getState().status, errorKind); - - if (!shouldRetry) { - break; - } - - console.warn('[chat.history] startup retry scheduled', { - sessionKey: currentSessionKey, - attempt: attempt + 1, - gatewayState: useGatewayStore.getState().status.state, - errorKind, - error: String(lastError), - }); - await sleep(CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS[attempt]!); - } - - if (data) { - let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : []; - const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null; - if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) { - rawMessages = fallbackMessages.length > 0 - ? fallbackMessages - : await loadLocalHistoryFallback(currentSessionKey, 200); - } else if (rawMessages.length > 0) { - rawMessages = await hydrateGatewayHistoryFromTranscript( - currentSessionKey, - rawMessages, - HISTORY_PAGE_SIZE, - get().messages, - ); - } - - if (rawMessages.length === 0 && localFallbackApplied && !isCronSessionKey(currentSessionKey)) { - set({ loading: false }); - return; - } - - const applied = applyLoadedMessages(rawMessages, thinkingLevel); - if (applied) { - set({ hasMoreHistory: rawMessages.length >= HISTORY_PAGE_SIZE }); - } - if (applied && isInitialForegroundLoad) { - _foregroundHistoryLoadSeen.add(foregroundLoadKey); - void refreshVisibleSessionSummaries(set, get); - } - } else { - const errorKind = classifyHistoryStartupRetryError(lastError); - if (isCurrentSession() && isInitialForegroundLoad && errorKind) { - console.warn('[chat.history] startup retry exhausted', { - sessionKey: currentSessionKey, - gatewayState: useGatewayStore.getState().status.state, - error: String(lastError), - }); - } - - const appliedLateFallback = fallbackMessages.length > 0 - ? applyLoadedMessages(fallbackMessages, null) - : await applyLocalFallbackMessages(); - if (appliedLateFallback) { - if (fallbackMessages.length > 0) { - localFallbackApplied = true; - set({ hasMoreHistory: fallbackMessages.length >= HISTORY_PAGE_SIZE }); - if (isInitialForegroundLoad) { - _foregroundHistoryLoadSeen.add(foregroundLoadKey); - void refreshVisibleSessionSummaries(set, get); - } - } - } else if (localFallbackApplied) { - set({ loading: false }); - } else if (errorKind === 'timeout' && isInitialForegroundLoad) { - // Keep startup usable while Gateway RPC routing catches up. The - // Sidebar/gateway event refreshes will retry quietly instead of - // showing a transient "RPC timeout: chat.history" error. - set({ loading: false }); - } else { - applyLoadFailure( - (lastError instanceof Error ? lastError.message : String(lastError)) - || 'Failed to load chat history', - ); - } - } - } catch (err) { - console.warn('Failed to load chat history:', err); - const applied = await applyLocalFallbackMessages(); - if (!applied && localFallbackApplied) { - set({ loading: false }); - } else if (!applied) { - applyLoadFailure(String(err)); - } - } finally { - gatewayHistorySettled = true; - } - }; - - const gatewayLoadPromise = loadGatewayHistory(); - if (isInitialForegroundLoad && shouldShowForegroundLoading) { - await Promise.race([ - gatewayLoadPromise.then(() => 'gateway' as const), - applyStartupFallbackAfterGrace(), - ]); - } - await gatewayLoadPromise; - })(); - - _historyLoadInFlight.set(currentSessionKey, loadPromise); - try { - await loadPromise; - } finally { - // Clear the safety timer on normal completion - if (loadingSafetyTimer) clearTimeout(loadingSafetyTimer); - if (!loadingTimedOut) { - // Only update load time if we actually didn't time out and the - // completed request still belongs to the selected session. Stale - // loads from a session switch must not debounce the next foreground - // startup attempt for that same session. - if (get().currentSessionKey === currentSessionKey) { - _lastHistoryLoadAtBySession.set(currentSessionKey, Date.now()); - } - } - - const active = _historyLoadInFlight.get(currentSessionKey); - if (active === loadPromise) { - _historyLoadInFlight.delete(currentSessionKey); - } - } - }, - - loadMoreHistory: async () => { - const { currentSessionKey, messages, loadingMoreHistory, hasMoreHistory } = get(); - if (loadingMoreHistory || !hasMoreHistory || messages.length === 0) return; - - set({ loadingMoreHistory: true, error: null }); - try { - const nextLimit = Math.min(messages.length + HISTORY_PAGE_SIZE, HISTORY_MAX_RENDERED_MESSAGES); - const rawMessages = await loadLocalHistoryFallback(currentSessionKey, nextLimit); - if (get().currentSessionKey !== currentSessionKey) return; - if (rawMessages.length === 0) { - set({ hasMoreHistory: false, loadingMoreHistory: false }); - return; - } - - // Reuse the normal history application path by replacing the visible - // window with a larger suffix from the transcript. This keeps render - // cost bounded while allowing long conversations to page backwards. - const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); - const messagesWithToolAttachments = enrichWithToolCallAttachments(messagesWithToolImages); - const filteredMessages = messagesWithToolAttachments.filter((msg) => !shouldDropMessageFromHistory(msg)); - const enrichedMessages = enrichWithCachedImages(filteredMessages); - set({ - messages: enrichedMessages, - loadingMoreHistory: false, - hasMoreHistory: rawMessages.length >= nextLimit && nextLimit < HISTORY_MAX_RENDERED_MESSAGES, - }); - cacheSessionHistory(currentSessionKey, enrichedMessages, get().thinkingLevel); - void loadMissingPreviews(enrichedMessages).then((updated) => { - if (!updated || get().currentSessionKey !== currentSessionKey) return; - set((state) => ({ - messages: state.messages.map((message) => { - const match = enrichedMessages.find((candidate) => ( - `${candidate.id ?? ''}|${candidate.role}|${candidate.timestamp ?? ''}|${getMessageText(candidate.content)}` - === `${message.id ?? ''}|${message.role}|${message.timestamp ?? ''}|${getMessageText(message.content)}` - )); - return match?._attachedFiles?.length ? { ...message, _attachedFiles: match._attachedFiles } : message; - }), - })); - }); - } catch (error) { - console.warn('Failed to load more history:', error); - set({ loadingMoreHistory: false, error: String(error) }); - } finally { - if (get().currentSessionKey === currentSessionKey) { - set({ loadingMoreHistory: false }); - } - } - }, - - // ── Send message ── - - sendMessage: async ( - text: string, - attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>, - targetAgentId?: string | null, - ) => { - const trimmed = text.trim(); - if (!trimmed && (!attachments || attachments.length === 0)) return; - - const targetSessionKey = resolveMainSessionKeyForAgent(targetAgentId) ?? get().currentSessionKey; - - // Guard against double-submit before React re-renders with sending=true. - if (get().sending && targetSessionKey === get().currentSessionKey) { - return; - } - - if (targetSessionKey !== get().currentSessionKey) { - set((s) => buildSessionSwitchPatch(s, targetSessionKey)); - await get().loadHistory(true); - } - - const currentSessionKey = targetSessionKey; - const sendGeneration = ++_sendGenerationCounter; - _activeSendGenerationBySession.set(currentSessionKey, sendGeneration); - - // Add user message optimistically (with local file metadata for UI display) - const nowMs = Date.now(); - const userMsg: RawMessage = { - role: 'user', - content: trimmed || (attachments?.length ? '(file attached)' : ''), - timestamp: nowMs / 1000, - id: crypto.randomUUID(), - _attachedFiles: attachments?.map(a => ({ - fileName: a.fileName, - mimeType: a.mimeType, - fileSize: a.fileSize, - preview: a.preview, - filePath: a.stagedPath, - })), - }; - rememberPendingOptimisticUserMessage(currentSessionKey, userMsg, nowMs); - set((s) => ({ - messages: [...s.messages, userMsg], - sending: true, - error: null, - runError: null, - dismissedRunErrors: withoutDismissedRunError(s.dismissedRunErrors, currentSessionKey), - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: nowMs, - })); - - // Update session label with first user message text as soon as it's sent - const { sessionLabels, messages, sessions } = get(); - const isFirstMessage = !messages.slice(0, -1).some((m) => m.role === 'user'); - const currentSession = sessions.find((session) => session.key === currentSessionKey); - if (!currentSessionKey.endsWith(':main') && !hasExplicitSessionLabel(currentSession) && isFirstMessage && !sessionLabels[currentSessionKey] && trimmed) { - const labelText = toSessionLabel(trimmed); - if (labelText) { - set((s) => ({ sessionLabels: { ...s.sessionLabels, [currentSessionKey]: labelText } })); - } - } - - // Mark this session as most recently active - set((s) => ({ sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: nowMs } })); - - // Runtime progress now comes from Main-owned streamed events. We still - // keep the no-response safety timeout, but history polling is no longer - // the primary active-run path. - _lastChatEventAt = Date.now(); - clearHistoryPoll(); - clearErrorRecoveryTimer(); - - // Fallback transcript poll: streamed runtime events are the primary - // active-run path, but when they go missing entirely (first run right - // after gateway startup, silent WS drops, event-normalization gaps) the - // safety timeout above would fire a false "No response received" error - // even though the gateway is making progress. Polling chat.history keeps - // progress detection honest in that case. The RPC is skipped while - // streamed events are fresh, so healthy runs issue no extra requests. - const pollHistoryFallback = () => { - _historyPollTimer = null; - const state = get(); - if (!state.sending || state.currentSessionKey !== currentSessionKey) return; - if (Date.now() - _lastChatEventAt >= HISTORY_POLL_EVENT_SILENCE_MS) { - void state.loadHistory(true); - } - _historyPollTimer = setTimeout(pollHistoryFallback, HISTORY_POLL_INTERVAL_MS); - }; - _historyPollTimer = setTimeout(pollHistoryFallback, HISTORY_POLL_START_DELAY_MS); - - const checkStuck = () => { - const state = get(); - if (!state.sending) return; - - const hasStream = hasMeaningfulStreamingActivity( - state.streamingMessage, - state.streamingText, - state.streamingTools, - ); - if (hasStream) { - setTimeout(checkStuck, 10_000); - return; - } - - // Gateway run-start / model-switch deltas can set `{ role: 'assistant' }` - // with no payload. That placeholder must not block the safety timeout. - if (state.streamingMessage || state.streamingText) { - set({ streamingMessage: null, streamingText: '' }); - } - - const sendAgeMs = state.lastUserMessageAt - ? Date.now() - toMs(state.lastUserMessageAt) - : 0; - const hasProgress = hasAssistantProgressSinceSend(state.messages, state.lastUserMessageAt); - - if (sendAgeMs >= LLM_IDLE_HINT_MS && !state.runError && !hasProgress) { - set({ - runError: 'The model did not respond within 120 seconds. Retrying…', - }); - } - - if (state.pendingFinal) { - if (hasProgress) { - setTimeout(checkStuck, 10_000); - return; - } - set({ pendingFinal: false }); - } - - if (hasProgress) { - _lastChatEventAt = Date.now(); - if (state.error || state.runError) { - set({ error: null, runError: null }); - } - setTimeout(checkStuck, 10_000); - return; - } - - if (Date.now() - _lastChatEventAt < NO_RESPONSE_SAFETY_TIMEOUT_MS) { - setTimeout(checkStuck, 10_000); - return; - } - - clearHistoryPoll(); - set({ - error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.', - sending: false, - activeRunId: null, - lastUserMessageAt: null, - pendingFinal: false, - streamingMessage: null, - streamingText: '', - }); - }; - setTimeout(checkStuck, 30_000); - - const clearSendGenerationIfCurrent = () => { - if (_activeSendGenerationBySession.get(currentSessionKey) === sendGeneration) { - _activeSendGenerationBySession.delete(currentSessionKey); - } - }; - - const applySendFailure = (errorMsg: string) => { - const latest = get(); - const sendStillCurrent = _activeSendGenerationBySession.get(currentSessionKey) === sendGeneration; - const canApplyToCurrentSession = latest.currentSessionKey === currentSessionKey - && latest.lastUserMessageAt === nowMs; - - if (sendStillCurrent && canApplyToCurrentSession) { - clearSendGenerationIfCurrent(); - clearHistoryPoll(); - set({ error: errorMsg, sending: false }); - return; - } - - if (sendStillCurrent && latest.currentSessionKey !== currentSessionKey) { - const cached = _sessionRunStateCache.get(currentSessionKey); - if (cached?.lastUserMessageAt === nowMs) { - clearSendGenerationIfCurrent(); - _sessionRunStateCache.set(currentSessionKey, DEFAULT_SESSION_RUN_STATE); - return; - } - } - - console.warn('[sendMessage] Ignoring stale chat.send failure', { - error: errorMsg, - sessionKey: currentSessionKey, - }); - }; - - try { - const idempotencyKey = crypto.randomUUID(); - const hasMedia = attachments && attachments.length > 0; - if (hasMedia) { - console.log('[sendMessage] Media paths:', attachments!.map(a => a.stagedPath)); - } - - // Cache image attachments BEFORE the IPC call to avoid race condition: - // history may reload (via Gateway event) before the RPC returns. - // Keyed by staged file path which appears in [media attached: ...]. - if (hasMedia && attachments) { - for (const a of attachments) { - _imageCache.set(a.stagedPath, { - fileName: a.fileName, - mimeType: a.mimeType, - fileSize: a.fileSize, - preview: a.preview, - }); - } - saveImageCache(_imageCache); - } - - let result: ChatSendWithMediaResult; - - if (hasMedia) { - result = await hostApi.chat.sendWithMedia({ - sessionKey: currentSessionKey, - message: trimmed || 'Process the attached file(s).', - deliver: false, - idempotencyKey, - media: attachments.map((a) => ({ - filePath: a.stagedPath, - mimeType: a.mimeType, - fileName: a.fileName, - })), - }); - } else { - const rpcResult = await sendChatMessageViaHostApi({ - sessionKey: currentSessionKey, - message: trimmed, - deliver: false, - idempotencyKey, - }); - result = { success: true, result: rpcResult }; - } - - console.log(`[sendMessage] RPC result: success=${result.success}, runId=${result.result?.runId || 'none'}`); - - if (!result.success) { - const errorMsg = result.error || 'Failed to send message'; - if (isRecoverableChatSendTimeout(errorMsg)) { - console.warn(`[sendMessage] Recoverable chat.send timeout, keeping poll alive: ${errorMsg}`); - } else { - applySendFailure(errorMsg); - } - } else if (result.result?.runId) { - const returnedRunId = result.result.runId; - const latest = get(); - const sendStillCurrent = _activeSendGenerationBySession.get(currentSessionKey) === sendGeneration; - const canAttachToCurrentSession = latest.currentSessionKey === currentSessionKey - && latest.sending - && latest.lastUserMessageAt === nowMs - && (latest.activeRunId == null || latest.activeRunId === returnedRunId); - - if (sendStillCurrent && canAttachToCurrentSession) { - set({ activeRunId: returnedRunId }); - } else if (sendStillCurrent && latest.currentSessionKey !== currentSessionKey) { - const cached = _sessionRunStateCache.get(currentSessionKey); - if (cached?.sending - && cached.lastUserMessageAt === nowMs - && (cached.activeRunId == null || cached.activeRunId === returnedRunId)) { - captureSessionRunState(currentSessionKey, { ...cached, activeRunId: returnedRunId }); - } - } else { - console.warn('[sendMessage] Ignoring stale chat.send runId', { - runId: returnedRunId, - sessionKey: currentSessionKey, - }); - } - } - } catch (err) { - const errStr = String(err); - if (isRecoverableChatSendTimeout(errStr)) { - console.warn(`[sendMessage] Recoverable chat.send timeout, keeping poll alive: ${errStr}`); - } else { - applySendFailure(errStr); - } - } - }, - - // ── Abort active run ── - - abortRun: async () => { - clearHistoryPoll(); - clearErrorRecoveryTimer(); - const { currentSessionKey } = get(); - set({ sending: false, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [] }); - set({ streamingTools: [] }); - - try { - await abortChatRunViaHostApi(currentSessionKey); - } catch (err) { - set({ error: String(err) }); - } - }, - - // ── Handle incoming chat events from Gateway ── - - handleChatEvent: (event: Record) => { - const runId = String(event.runId || ''); - const eventState = String(event.state || ''); - const eventSessionKey = event.sessionKey != null ? String(event.sessionKey) : null; - const { activeRunId, currentSessionKey } = get(); - - // Only process events for the current session (when sessionKey is present). - // Cron runtime/chat events arrive under the run-scoped key - // (agent::cron::run:) while the UI tracks the base - // cron key — treat those as the same session via equivalence. - const matchesCurrentSession = eventSessionKey == null - || sessionKeysAreEquivalent(eventSessionKey, currentSessionKey); - if (eventSessionKey != null && !matchesCurrentSession) { - return; - } - - // Only process events for the active run (or if no active run set). - // Inbound channel traffic (Feishu/Telegram/etc.) on the current session uses a - // different runId than a stale desktop activeRunId — still refresh history on finals. - if (activeRunId && runId && runId !== activeRunId) { - const isCurrentSession = matchesCurrentSession; - const inboundTerminal = eventState === 'final' || eventState === 'error' - || (event.message && typeof event.message === 'object' - && getMessageStopReason(event.message as Record) != null); - if (isCurrentSession && inboundTerminal) { - void get().loadHistory(true); - } - return; - } - - if (isDuplicateChatEvent(eventState, event)) { - return; - } - - _lastChatEventAt = Date.now(); - - // Defensive: if state is missing but we have a message, try to infer state. - let resolvedState = eventState; - if (!resolvedState && event.message && typeof event.message === 'object') { - const msg = event.message as Record; - const stopReason = getMessageStopReason(msg); - if (stopReason === 'error') { - resolvedState = 'error'; - } else if (stopReason) { - resolvedState = 'final'; - } else if (msg.role || msg.content) { - resolvedState = 'delta'; - } - } - - // Streaming data pauses the fallback transcript poll implicitly: each - // event refreshes _lastChatEventAt, so the poll skips its RPC while the - // stream is healthy. Do NOT clear the poll timer here — it must stay - // armed to recover progress tracking if the stream stalls mid-run. - const hasUsefulData = resolvedState === 'delta' || resolvedState === 'final' - || resolvedState === 'error' || resolvedState === 'aborted'; - if (hasUsefulData) { - // Adopt run started from another client only for user-initiated turns. - // Background :main heartbeat runs must not surface "Thinking..." in the UI. - const { sending } = get(); - if (!sending && runId && shouldTrackInboundRunLifecycle(get(), currentSessionKey)) { - set({ sending: true, activeRunId: runId, error: null, runError: null }); - } - } - - switch (resolvedState) { - case 'started': { - const { sending: currentSending } = get(); - if (!currentSending && runId && shouldTrackInboundRunLifecycle(get(), currentSessionKey)) { - set({ sending: true, activeRunId: runId, error: null, runError: null }); - } - break; - } - case 'delta': { - // Clear any stale error (including RPC timeout) when new data arrives. - if (_errorRecoveryTimer) { - clearErrorRecoveryTimer(); - } - if (get().error || get().runError) { - set({ error: null, runError: null }); - } - const updates = collectToolUpdates(event.message, resolvedState); - // Capture baseline file content from disk before the runtime - // executes Write tool calls — enables proper before/after diff. - captureBaselinesFromMessage( - event.message, - getBaselineRunKeyForMessages(currentSessionKey, get().messages), - ); - set((s) => ({ - streamingMessage: (() => { - if (event.message && typeof event.message === 'object') { - const msgRole = (event.message as RawMessage).role; - if (isToolResultRole(msgRole)) return s.streamingMessage; - } - return normalizeStreamingMessage(event.message ?? s.streamingMessage); - })(), - streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, - })); - break; - } - case 'final': { - clearErrorRecoveryTimer(); - if (get().error || get().runError) set({ error: null, runError: null }); - // Message complete - add to history and clear streaming - const finalMsg = event.message as RawMessage | undefined; - if (finalMsg) { - const normalizedFinalMessage = normalizeStreamingMessage(finalMsg) as RawMessage; - if (isTerminalAssistantErrorMessage(normalizedFinalMessage)) { - get().handleChatEvent({ - ...event, - state: 'error', - errorMessage: getMessageErrorMessage(normalizedFinalMessage) ?? event.errorMessage, - message: normalizedFinalMessage, - }); - break; - } - const updates = collectToolUpdates(normalizedFinalMessage, resolvedState); - // Filter out internal-only final responses (NO_REPLY, HEARTBEAT_OK, etc.) - // before adding to messages. Without this guard, the internal token appears - // briefly in the UI until loadHistory replaces the message list — and if the - // quiet-mode reload is debounced away, the token can stay visible permanently. - if (isInternalMessage(normalizedFinalMessage)) { - const sessionKeyForReload = get().currentSessionKey; - set({ - streamingText: '', - streamingMessage: null, - sending: false, - activeRunId: null, - pendingFinal: false, - streamingTools: [], - pendingToolImages: [], - }); - clearHistoryPoll(); - forceNextHistoryLoad(sessionKeyForReload); - void get().loadHistory(true); - break; - } - if (isToolResultRole(normalizedFinalMessage.role)) { - // Resolve file path from the streaming assistant message's matching tool call - const currentStreamForPath = get().streamingMessage as RawMessage | null; - const matchedPath = (currentStreamForPath && normalizedFinalMessage.toolCallId) - ? getToolCallFilePath(currentStreamForPath, normalizedFinalMessage.toolCallId) - : undefined; - - // Mirror `enrichWithToolResultFiles`: collect non-image artifacts - // for the next assistant message. Images embedded inside a tool - // result (read tool's vision data) and raw image paths in the - // tool's stdout (sips / ls / file output) are NOT user-facing — - // the canonical render is the Gateway-injected `assistant-media` - // bubble that follows the agent's `MEDIA:` text. Surfacing those - // intermediate images here would duplicate every screenshot the - // agent inspects on its way to the final artifact. - const toolFiles: AttachedFileMeta[] = extractImagesAsAttachedFiles( - normalizedFinalMessage.content, - ).filter(file => !file.mimeType.startsWith('image/')); - const delivery = collectMessageToolDelivery(normalizedFinalMessage); - const deliveredFiles = delivery?.files ?? []; - const internalUiReply = delivery - ? createInternalUiDeliveryReply(normalizedFinalMessage, delivery) - : null; - if (matchedPath) { - for (const f of toolFiles) { - if (!f.filePath) { - f.filePath = matchedPath; - f.fileName = matchedPath.split(/[\\/]/).pop() || 'image'; - } - } - } - const text = getMessageText(normalizedFinalMessage.content); - if (text) { - const mediaRefs = extractMediaRefs(text); - const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); - for (const ref of mediaRefs) toolFiles.push(makeAttachedFile(ref)); - for (const ref of extractRawFilePaths(text)) { - if (mediaRefPaths.has(ref.filePath)) continue; - if (ref.mimeType.startsWith('image/')) continue; - toolFiles.push(makeAttachedFile(ref)); - } - } - set((s) => { - // Preserve the assistant turn that requested the tool before the - // tool result clears streaming state. Runtime events render the - // live execution graph, but the legacy chat-event path still - // needs this snapshot for providers/transports that do not emit - // complete runtime tool events. - const currentStream = s.streamingMessage as RawMessage | null; - const snapshotMsgs = snapshotStreamingAssistantMessage(currentStream, s.messages, runId); - const snapshotWithDeliveredFiles = !internalUiReply && deliveredFiles.length > 0 && snapshotMsgs.length > 0 - ? snapshotMsgs.map((snapshot, index) => index === snapshotMsgs.length - 1 - ? { - ...snapshot, - _attachedFiles: dedupeAttachedFiles([ - ...(snapshot._attachedFiles || []), - ...deliveredFiles, - ]), - } - : snapshot) - : snapshotMsgs; - const appendedMessages = [ - ...snapshotWithDeliveredFiles, - ...(internalUiReply ? [internalUiReply] : []), - ]; - return { - messages: appendedMessages.length > 0 ? [...s.messages, ...appendedMessages] : s.messages, - streamingText: '', - streamingMessage: null, - pendingFinal: true, - pendingToolImages: toolFiles.length > 0 || (!internalUiReply && deliveredFiles.length > 0 && snapshotWithDeliveredFiles.length === 0) - ? dedupeAttachedFiles([ - ...s.pendingToolImages, - ...toolFiles, - ...(!internalUiReply && snapshotWithDeliveredFiles.length === 0 ? deliveredFiles : []), - ]) - : s.pendingToolImages, - streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, - }; - }); - break; - } - // Mixed `[thinking, text, toolCall]` messages with stop_reason="tool_use" - // (some MiniMax / gpt-5.5 variants emit these) are still intermediate - // turns even though they carry user-visible text. Treat them as - // tool-only for lifecycle purposes so the run stays "open" until the - // truly final reply (without a pending tool call) arrives. - const pendingTool = hasPendingToolUse(normalizedFinalMessage); - const toolOnly = isToolOnlyMessage(normalizedFinalMessage) || pendingTool; - const hasOutput = !pendingTool && hasNonToolAssistantContent(normalizedFinalMessage); - // When the model ends its turn with only `thinking` blocks (no text, - // no images, no tool calls), `hasOutput` is false and `toolOnly` is - // false. This is a valid terminal state (the model decided not to - // produce user-visible content — common after image_generate + - // message-send tool chains on MiniMax-M2.7). Without this flag the - // lifecycle stays armed indefinitely, leaving the UI stuck on - // "Thinking…" even though the run is complete. - const isEmptyTerminalResponse = !toolOnly && !hasOutput && !pendingTool; - const clearLifecycle = hasOutput || isEmptyTerminalResponse; - const msgId = normalizedFinalMessage.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`); - set((s) => { - const nextTools = updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools; - const streamingTools = clearLifecycle ? [] : nextTools; - - // Note: it would be tempting to also surface `MEDIA:/path` - // markers from `normalizedFinalMessage.content`'s text here, so - // the agent's reply could attach the original file directly - // (`/tmp/...png`) without waiting for the post-final history - // reload. However, OpenClaw's `splitTrailingDirective` - // (selection-D8_ELZa7.js ~line 904) strips `MEDIA:/...` lines - // out of the streaming text BEFORE it reaches the client, so - // the `final` event we get here never contains the marker. - // Image surfacing is fully handled by the post-final reload - // below + `enrichWithCachedImages` (which dereferences the - // assistant-media bubble's `block.url`). - const pendingImgs = s.pendingToolImages; - const msgWithImages: RawMessage = pendingImgs.length > 0 - ? { - ...normalizedFinalMessage, - role: (normalizedFinalMessage.role || 'assistant') as RawMessage['role'], - id: msgId, - _attachedFiles: [...(normalizedFinalMessage._attachedFiles || []), ...pendingImgs], - } - : { ...normalizedFinalMessage, role: (normalizedFinalMessage.role || 'assistant') as RawMessage['role'], id: msgId }; - const clearPendingImages = { pendingToolImages: [] as AttachedFileMeta[] }; - // Check if message already exists (prevent duplicates) - const alreadyExists = s.messages.some(m => m.id === msgId); - if (alreadyExists) { - return toolOnly ? { - streamingText: '', - streamingMessage: null, - pendingFinal: true, - streamingTools, - ...clearPendingImages, - } : { - streamingText: '', - streamingMessage: null, - sending: clearLifecycle ? false : s.sending, - activeRunId: clearLifecycle ? null : s.activeRunId, - pendingFinal: clearLifecycle ? false : true, - streamingTools, - ...clearPendingImages, - }; - } - return toolOnly ? { - messages: [...s.messages, msgWithImages], - streamingText: '', - streamingMessage: null, - pendingFinal: true, - streamingTools, - ...clearPendingImages, - } : { - messages: [...s.messages, msgWithImages], - streamingText: '', - streamingMessage: null, - sending: clearLifecycle ? false : s.sending, - activeRunId: clearLifecycle ? null : s.activeRunId, - pendingFinal: clearLifecycle ? false : true, - streamingTools, - ...clearPendingImages, - }; - }); - // After the final response, quietly reload history to surface all intermediate - // tool-use turns (thinking + tool blocks) from the Gateway's authoritative record. - // Also reload for empty terminal responses (thinking-only) so the - // delayed follow-up can pick up the Gateway's `assistant-media` - // bubble that may still be getting written. - if (clearLifecycle && !toolOnly) { - clearHistoryPoll(); - captureSessionRunState(get().currentSessionKey, DEFAULT_SESSION_RUN_STATE); - void get().loadHistory(true); - - // OpenClaw's gateway processes `MEDIA:/path` markers in the - // assistant reply asynchronously, in the `dispatch.deliver` of - // the `final` payload (see openclaw/dist/chat-DM9hSaNV.js's - // `appendWebchatAgentMediaTranscriptIfNeeded`): - // 1. copy the original file under - // `~/.openclaw/media/outgoing/originals/` - // 2. write the record JSON under - // `~/.openclaw/media/outgoing/records/.json` - // 3. `appendAssistantTranscriptMessage` writes a follow-up - // `assistant-media` message to the session JSONL, with - // `idempotencyKey: ":assistant-media"`. - // That follow-up message is **only persisted** — it is NOT - // re-broadcast as a streaming event. The streaming `final` - // we just consumed only contains the agent's text. The - // assistant-media bubble can only be retrieved via - // `chat.history`, and the persistence runs on the order of - // ~400-500ms after the streaming final. - // - // The immediate `loadHistory(true)` above therefore races the - // gateway's write and almost always misses the bubble. - // - // CRITICAL: we cannot detect from the streaming final alone - // whether the agent emitted a `MEDIA:/path` marker — OpenClaw's - // `splitTrailingDirective` (selection-D8_ELZa7.js line ~904) - // strips `MEDIA:/...` lines from the broadcast text BEFORE it - // reaches the client, so the streaming `final` text is always - // the user-facing prose without the marker. The MEDIA: marker - // only appears in the persisted JSONL transcript (msg N) and - // its companion `assistant-media` bubble (msg N+1). - // - // We therefore unconditionally schedule ONE follow-up quiet - // reload ~1500ms after every assistant `final`. The cost is - // a single extra in-process RPC per assistant turn (cheap); - // when there's no media the second reload returns the same - // history snapshot and is a no-op for the UI. - // `forceNextHistoryLoad` bypasses `HISTORY_LOAD_MIN_INTERVAL_MS` - // so the call is not suppressed by the throttle. - const sessionKeyAtFinal = get().currentSessionKey; - setTimeout(() => { - if (get().currentSessionKey !== sessionKeyAtFinal) { - return; - } - forceNextHistoryLoad(sessionKeyAtFinal); - void get().loadHistory(true); - }, 1500); - } - } else { - // No message in final event - reload history to get complete data - set({ streamingText: '', streamingMessage: null, pendingFinal: true }); - get().loadHistory(); - } - break; - } - case 'error': { - const errorMsg = String( - event.errorMessage - || getMessageErrorMessage(event.message) - || 'An error occurred', - ); - const terminalAssistantError = isTerminalAssistantErrorMessage(event.message); - const wasSending = get().sending; - const sessionKeyAtError = get().currentSessionKey; - const recoverable = wasSending && isRecoverableRuntimeError(errorMsg); - - const commitRuntimeError = () => { - const currentStream = get().streamingMessage as RawMessage | null; - const errorSnapshot = snapshotStreamingAssistantMessage( - currentStream, - get().messages, - `error-${runId || Date.now()}`, - ); - if (errorSnapshot.length > 0) { - set((s) => ({ - messages: [...s.messages, ...errorSnapshot], - })); - } - - set({ - error: terminalAssistantError ? null : errorMsg, - runError: terminalAssistantError ? errorMsg : null, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - }); - - clearHistoryPoll(); - clearErrorRecoveryTimer(); - if (wasSending) { - void get().loadHistory(true); - } - }; - - if (recoverable) { - scheduleRecoverableRuntimeError(() => { - if (get().currentSessionKey !== sessionKeyAtError) return; - if (runId && get().activeRunId && get().activeRunId !== runId) return; - if (!get().sending && !get().error && !get().runError) return; - commitRuntimeError(); - }); - break; - } - - commitRuntimeError(); - break; - } - case 'aborted': { - clearHistoryPoll(); - clearErrorRecoveryTimer(); - set({ - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - }); - break; - } - default: { - // Unknown or empty state — if we're currently sending and receive an event - // with a message, attempt to process it as streaming data. This handles - // edge cases where the Gateway sends events without a state field. - const { sending } = get(); - if (sending && event.message && typeof event.message === 'object') { - console.warn(`[handleChatEvent] Unknown event state "${resolvedState}", treating message as streaming delta. Event keys:`, Object.keys(event)); - const updates = collectToolUpdates(event.message, 'delta'); - set((s) => ({ - streamingMessage: event.message ?? s.streamingMessage, - streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, - })); - } - break; - } - } - }, - - handleRuntimeEvent: (event: ChatRuntimeEvent) => { - const eventSessionKey = event.sessionKey ?? null; - const initialState = get(); - const { activeRunId, currentSessionKey } = initialState; - // Cron runs stream under the run-scoped session key while the UI tracks the - // base cron key; equivalence binds those run-scoped events to the session - // the user is viewing so the live graph/Thinking state renders in realtime. - const matchesCurrentSession = eventSessionKey != null - && sessionKeysAreEquivalent(eventSessionKey, currentSessionKey); - const matchesActiveRun = activeRunId != null && event.runId === activeRunId; - - const runtimeRuns = applyRuntimeEventToRuns(initialState.runtimeRuns, event); - const nextPatch: Partial = { runtimeRuns }; - - // Always retain structured runtime events, even for inactive sessions. - // When the user switches away during a run and returns later, the Chat page - // must be able to reconstruct the live execution graph from runtimeRuns - // instead of relying only on the on-disk transcript snapshot. - // Session-less runtime events are only safe to apply to active UI when they - // match the active run; otherwise they are stored but do not affect the - // current composer/graph state. - if (!matchesCurrentSession && !matchesActiveRun) { - updateCachedSessionRunStateFromRuntimeEvent(event); - set(nextPatch); - return; - } - - _lastChatEventAt = Date.now(); - const appliesToActiveUi = matchesActiveRun || (activeRunId == null && matchesCurrentSession); - - if (event.type === 'run.started') { - if (matchesCurrentSession && (activeRunId == null || matchesActiveRun)) { - nextPatch.activeRunId = event.runId; - nextPatch.error = null; - nextPatch.runError = null; - if (!initialState.sending && shouldTrackInboundRunLifecycle(initialState, currentSessionKey)) { - nextPatch.sending = true; - } - } - set(nextPatch); - return; - } - - // Adopt an in-progress run when joining it mid-flight. Opening a cron - // session whose scheduled run is already executing means `run.started` was - // emitted before the renderer began tracking this session, so streamed - // delta/tool events arrive with no `activeRunId`. Without adoption the live - // execution graph and the running/Thinking indicator never appear until the - // user switches sessions. Gated on `shouldTrackInboundRunLifecycle` so - // background `:main` heartbeat runs stay silent. - if ( - event.type !== 'run.ended' - && matchesCurrentSession - && activeRunId == null - && !initialState.sending - && shouldTrackInboundRunLifecycle(initialState, currentSessionKey) - ) { - nextPatch.activeRunId = event.runId; - nextPatch.sending = true; - nextPatch.error = null; - nextPatch.runError = null; - } - - if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { - if (appliesToActiveUi && (initialState.error || initialState.runError)) { - nextPatch.error = null; - nextPatch.runError = null; - } - set(nextPatch); - return; - } - - const toolStatus = runtimeToolEventToStatus(event); - if (toolStatus && appliesToActiveUi && (initialState.error || initialState.runError)) { - nextPatch.error = null; - nextPatch.runError = null; - } - - if (event.type === 'tool.completed' && appliesToActiveUi) { - const files = extractToolCompletedFiles(event); - if (files.length > 0) { - nextPatch.pendingToolImages = dedupeAttachedFiles([ - ...initialState.pendingToolImages, - ...files, - ]); - } - } - - if (event.type === 'run.ended') { - const latestState = get(); - const terminalMatchesActiveRun = latestState.activeRunId != null && event.runId === latestState.activeRunId; - const terminalIsForCurrentUntrackedSend = latestState.activeRunId == null - && matchesCurrentSession - && latestState.sending - && ( - typeof event.ts !== 'number' - || latestState.lastUserMessageAt == null - || event.ts >= latestState.lastUserMessageAt - 1_000 - ); - const shouldClearActiveRun = terminalMatchesActiveRun || terminalIsForCurrentUntrackedSend; - - if (shouldClearActiveRun) { - nextPatch.sending = false; - nextPatch.activeRunId = null; - nextPatch.pendingFinal = false; - nextPatch.lastUserMessageAt = null; - nextPatch.streamingTools = []; - if (event.status === 'error' && event.error) { - nextPatch.error = null; - nextPatch.runError = event.error; - } - if (event.status === 'aborted') { - nextPatch.streamingMessage = null; - nextPatch.streamingText = ''; - nextPatch.pendingToolImages = []; - } - } - } - - set(nextPatch); - }, - - // ── Refresh: reload history + sessions ── - - refresh: async () => { - const { loadHistory, loadSessions } = get(); - await Promise.all([loadHistory(), loadSessions()]); - }, - - clearError: () => { - const { runError, currentSessionKey, dismissedRunErrors } = get(); - set({ - error: null, - runError: null, - ...(runError - ? { dismissedRunErrors: { ...dismissedRunErrors, [currentSessionKey]: runError } } - : {}), - }); - }, })); - -export function syncCachedSessionRunIdle(sessionKey: string): void { - captureSessionRunState(sessionKey, DEFAULT_SESSION_RUN_STATE); -} diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts deleted file mode 100644 index 2bfff6d8..00000000 --- a/src/stores/chat/helpers.ts +++ /dev/null @@ -1,1959 +0,0 @@ -import { hostApi } from '@/lib/host-api'; -import { - isGeneratingStatusNarration, - isInternalAssistantReplyText, - isOpenClawRuntimeEventPrompt, -} from '@/pages/Chat/message-utils'; -import type { AttachedFileMeta, ChatSession, ContentBlock, RawMessage, ToolStatus } from './types'; - -// Module-level timestamp tracking the last chat event received. -// Used by the safety timeout to avoid false-positive "no response" errors -// during tool-use conversations where streamingMessage is temporarily cleared -// between tool-result finals and the next delta. -let _lastChatEventAt = 0; - -/** Normalize a timestamp to milliseconds. Handles both seconds and ms. */ -function toMs(ts: number): number { - // Timestamps < 1e12 are in seconds (before ~2033); >= 1e12 are milliseconds - return ts < 1e12 ? ts * 1000 : ts; -} - -// Timer for fallback history polling during active sends. -// If no streaming events arrive within a few seconds, we periodically -// poll chat.history to surface intermediate tool-call turns. -let _historyPollTimer: ReturnType | null = null; - -// Timer for delayed error finalization. When the Gateway reports a mid-stream -// error (e.g. "terminated"), it may retry internally and recover. We wait -// before committing the error to give the recovery path a chance. -let _errorRecoveryTimer: ReturnType | null = null; - -// Track the last run ID that was explicitly aborted by the user. -// Prevents lingering Gateway events from the aborted run from re-arming -// the sending state after abortRun clears it. -let _lastAbortedRunId: string | null = null; -const _blockedRunEvents = new Map[]>(); -const OPTIMISTIC_USER_MESSAGE_TTL_MS = 30 * 60 * 1000; -/** Max skew between the renderer optimistic send time and Gateway transcript timestamps. */ -const OPTIMISTIC_USER_TIMESTAMP_MATCH_MS = 120_000; -/** Grace period before surfacing mid-run Gateway errors that often self-recover. */ -const ERROR_RECOVERY_DELAY_MS = 12_000; - -type PendingOptimisticUserMessage = { - message: RawMessage; - timestampMs: number; - createdAtMs: number; -}; - -const _pendingOptimisticUserMessages = new Map(); - -function clearErrorRecoveryTimer(): void { - if (_errorRecoveryTimer) { - clearTimeout(_errorRecoveryTimer); - _errorRecoveryTimer = null; - } -} - -function isRecoverableRuntimeError(errorMessage: string): boolean { - const normalized = errorMessage.trim().toLowerCase(); - if (!normalized) return false; - return /\bterminated\b/.test(normalized) - || /\baborted\b/.test(normalized) - || normalized.includes('econnreset') - || normalized.includes('connection reset'); -} - -function scheduleRecoverableRuntimeError(commit: () => void): void { - clearErrorRecoveryTimer(); - _errorRecoveryTimer = setTimeout(() => { - _errorRecoveryTimer = null; - commit(); - }, ERROR_RECOVERY_DELAY_MS); -} - -function clearHistoryPoll(): void { - if (_historyPollTimer) { - clearTimeout(_historyPollTimer); - _historyPollTimer = null; - } -} - -// ── Local image cache ───────────────────────────────────────── -// The Gateway doesn't store image attachments in session content blocks, -// so we cache them locally keyed by staged file path (which appears in the -// [media attached: ...] reference in the Gateway's user message text). -// Keying by path avoids the race condition of keying by runId (which is only -// available after the RPC returns, but history may load before that). -const IMAGE_CACHE_KEY = 'clawx:image-cache'; -const IMAGE_CACHE_MAX = 100; // max entries to prevent unbounded growth - -function loadImageCache(): Map { - try { - const raw = localStorage.getItem(IMAGE_CACHE_KEY); - if (raw) { - const entries = JSON.parse(raw) as Array<[string, AttachedFileMeta]>; - return new Map(entries); - } - } catch { /* ignore parse errors */ } - return new Map(); -} - -function saveImageCache(cache: Map): void { - try { - // Evict oldest entries if over limit - const entries = Array.from(cache.entries()); - const trimmed = entries.length > IMAGE_CACHE_MAX - ? entries.slice(entries.length - IMAGE_CACHE_MAX) - : entries; - localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(trimmed)); - } catch { /* ignore quota errors */ } -} - -const _imageCache = loadImageCache(); - -function normalizeBlockText(text: string | undefined): string { - return typeof text === 'string' ? text.replace(/\r\n/g, '\n').trim() : ''; -} - -function compactProgressiveTextParts(parts: string[]): string[] { - const compacted: string[] = []; - - for (const part of parts) { - const current = normalizeBlockText(part); - if (!current) continue; - - const previous = compacted.at(-1); - if (!previous) { - compacted.push(part); - continue; - } - - const normalizedPrevious = normalizeBlockText(previous); - if (!normalizedPrevious) { - compacted[compacted.length - 1] = part; - continue; - } - - if (current === normalizedPrevious || normalizedPrevious.startsWith(current)) { - continue; - } - - if (current.startsWith(normalizedPrevious)) { - compacted[compacted.length - 1] = part; - continue; - } - - compacted.push(part); - } - - return compacted; -} - -function normalizeLiveContentBlocks(content: ContentBlock[]): ContentBlock[] { - return content.map((block) => ({ ...block })); -} - -function normalizeStreamingMessage(message: unknown): unknown { - if (!message || typeof message !== 'object') return message; - - const rawMessage = message as RawMessage; - const rawContent = rawMessage.content; - if (!Array.isArray(rawContent)) return rawMessage; - - const normalizedContent = normalizeLiveContentBlocks(rawContent as ContentBlock[]); - const didChange = normalizedContent.some((block, index) => block !== rawContent[index]) - || normalizedContent.length !== rawContent.length; - - return didChange - ? { ...rawMessage, content: normalizedContent } - : rawMessage; -} - -/** - * Strip Gateway-injected metadata that does NOT exist on the renderer's - * optimistic user message but is echoed back when the Gateway persists it: - * - leading sender metadata `Sender (untrusted metadata): ...` - * - leading timestamp `[Wed 2026-04-22 10:30 GMT+8] ` - * - `[message_id: uuid]` tags sprinkled throughout the text - * - `[media attached: path (mime) | path]` references appended when the - * renderer sends attachments via `chat:sendWithMedia` - * - Gateway-injected "Conversation info (untrusted metadata): ..." blocks - * - * Keeping this aligned with `cleanUserText` in `pages/Chat/message-utils.ts` - * is important: the user bubble renders the cleaned text, so the comparison - * used to dedupe optimistic vs server echoes must operate on the same - * cleaned form — otherwise the same visible message renders twice. - * - * Order matters: the `[media attached: ...]` lines are commonly emitted - * BETWEEN the Sender block and the `[Mon ... GMT+8]` timestamp prefix. - * If we strip the timestamp before the media-attached lines, the timestamp - * regex (`^\s*\[(?:Mon|...)]`) can never match because the leading `[` is - * `[media attached:` instead — leaving the timestamp in the normalized - * comparison text and breaking optimistic-vs-echo dedupe. - */ -function stripInboundMediaVisionEnvelope(text: string): string { - if (!/\[Image\]/i.test(text) && !/^User text:/im.test(text) && !/\nDescription:\s*\n/i.test(text)) { - return text; - } - - let result = text.replace(/^\s*\[Image\]\s*\n?/i, ''); - - const userTextBlock = result.match(/^User text:\s*\n([\s\S]*?)(?:\n\s*Description:\s*\n[\s\S]*)?\s*$/i); - if (userTextBlock) { - const userText = userTextBlock[1].trim(); - return /^Process the attached file\(s\)\.\s*$/i.test(userText) ? '' : userText; - } - - return result.replace(/\n\s*Description:\s*\n[\s\S]*$/i, '').trim(); -} - -function stripGatewayUserMetadata(text: string): string { - return stripInboundMediaVisionEnvelope( - text - .replace(/\s*\[media attached:[^\]]*\]/g, '') - .replace(/\s*\[message_id:\s*[^\]]+\]/g, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^Sender\s*\([^)]*\)\s*:\s*[^\n]*(?:\n\s*)*/i, '') - .replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^Sender\s*:\s*[^\n]*(?:\n\s*)*/i, '') - .replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '') - .replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, ''), - ); -} - -function normalizeComparableUserText(content: unknown): string { - const text = stripGatewayUserMetadata(getMessageText(content)) - .replace(/\s+/g, ' ') - .trim(); - if (/^\(file attached\)$/i.test(text)) return ''; - return text; -} - -function getComparableAttachmentSignature(message: Pick): string { - const files = (message._attachedFiles || []) - .map((file) => file.filePath || `${file.fileName}|${file.mimeType}|${file.fileSize}`) - .filter(Boolean) - .sort(); - return files.join('::'); -} - -function matchesOptimisticUserMessage( - candidate: RawMessage, - optimistic: RawMessage, - optimisticTimestampMs: number, -): boolean { - if (candidate.role !== 'user') return false; - - const optimisticText = normalizeComparableUserText(optimistic.content); - const candidateText = normalizeComparableUserText(candidate.content); - const sameText = optimisticText.length > 0 && optimisticText === candidateText; - - const optimisticAttachments = getComparableAttachmentSignature(optimistic); - const candidateAttachments = getComparableAttachmentSignature(candidate); - const sameAttachments = optimisticAttachments.length > 0 && optimisticAttachments === candidateAttachments; - - const hasOptimisticTimestamp = Number.isFinite(optimisticTimestampMs) && optimisticTimestampMs > 0; - const hasCandidateTimestamp = candidate.timestamp != null; - const timestampMatches = hasOptimisticTimestamp && hasCandidateTimestamp - ? Math.abs(toMs(candidate.timestamp as number) - optimisticTimestampMs) < OPTIMISTIC_USER_TIMESTAMP_MATCH_MS - : false; - - if (sameText && sameAttachments) return true; - if (sameText && (!optimisticAttachments || !candidateAttachments) && (timestampMatches || !hasCandidateTimestamp)) return true; - if (sameAttachments && (!optimisticText || !candidateText) && (timestampMatches || !hasCandidateTimestamp)) return true; - - const optimisticHadAttachmentsOnly = optimisticAttachments.length > 0 && !optimisticText; - const candidateIsAttachmentEcho = !candidateText - && /\[(?:media attached:|\s*Image\s*\])/i.test(getMessageText(candidate.content)); - if (optimisticHadAttachmentsOnly && candidateIsAttachmentEcho && (timestampMatches || !hasCandidateTimestamp)) { - return true; - } - return false; -} - -function rememberPendingOptimisticUserMessage(sessionKey: string, message: RawMessage, timestampMs: number): void { - const now = Date.now(); - const existing = (_pendingOptimisticUserMessages.get(sessionKey) || []) - .filter((entry) => now - entry.createdAtMs <= OPTIMISTIC_USER_MESSAGE_TTL_MS); - existing.push({ message, timestampMs, createdAtMs: now }); - _pendingOptimisticUserMessages.set(sessionKey, existing); -} - -function clearPendingOptimisticUserMessages(sessionKey: string): void { - _pendingOptimisticUserMessages.delete(sessionKey); -} - -function mergePendingOptimisticUserMessages(sessionKey: string, loadedMessages: RawMessage[]): RawMessage[] { - const pending = _pendingOptimisticUserMessages.get(sessionKey); - if (!pending || pending.length === 0) return loadedMessages; - - const now = Date.now(); - let merged = loadedMessages; - const stillPending: PendingOptimisticUserMessage[] = []; - - for (const entry of pending) { - if (now - entry.createdAtMs > OPTIMISTIC_USER_MESSAGE_TTL_MS) { - continue; - } - - const hasServerEcho = hasOptimisticServerEcho(loadedMessages, entry.message, entry.timestampMs); - if (hasServerEcho) { - continue; - } - - const alreadyRendered = merged.some((message) => - message.id === entry.message.id || matchesOptimisticUserMessage(message, entry.message, entry.timestampMs), - ); - if (!alreadyRendered) { - const insertAt = merged.findIndex((message) => - typeof message.timestamp === 'number' && toMs(message.timestamp) > entry.timestampMs, - ); - merged = insertAt === -1 - ? [...merged, entry.message] - : [...merged.slice(0, insertAt), entry.message, ...merged.slice(insertAt)]; - } - - stillPending.push(entry); - } - - if (stillPending.length > 0) { - _pendingOptimisticUserMessages.set(sessionKey, stillPending); - } else { - _pendingOptimisticUserMessages.delete(sessionKey); - } - - return merged; -} - -function snapshotStreamingAssistantMessage( - currentStream: RawMessage | null, - existingMessages: RawMessage[], - runId: string, -): RawMessage[] { - if (!currentStream) return []; - - const normalizedStream = normalizeStreamingMessage(currentStream) as RawMessage; - const streamRole = normalizedStream.role; - if (streamRole !== 'assistant' && streamRole !== undefined) return []; - - const snapId = normalizedStream.id || `${runId || 'run'}-turn-${existingMessages.length}`; - if (existingMessages.some((message) => message.id === snapId)) return []; - - return [{ - ...normalizedStream, - role: 'assistant', - id: snapId, - }]; -} - -function getLatestOptimisticUserMessage(messages: RawMessage[], userTimestampMs: number): RawMessage | undefined { - return [...messages].reverse().find( - (message) => message.role === 'user' - && (!message.timestamp || Math.abs(toMs(message.timestamp) - userTimestampMs) < OPTIMISTIC_USER_TIMESTAMP_MATCH_MS), - ); -} - -function hasOptimisticServerEcho( - loadedMessages: RawMessage[], - optimistic: RawMessage, - optimisticTimestampMs: number, -): boolean { - if (loadedMessages.some((message) => - matchesOptimisticUserMessage(message, optimistic, optimisticTimestampMs), - )) { - return true; - } - - const optimisticText = normalizeComparableUserText(optimistic.content); - if (!optimisticText) return false; - - const matchingUsers = loadedMessages.filter( - (message) => message.role === 'user' - && normalizeComparableUserText(message.content) === optimisticText, - ); - if (matchingUsers.length !== 1) return false; - - const candidate = matchingUsers[0]!; - if (candidate.timestamp == null) return true; - - return Math.abs(toMs(candidate.timestamp as number) - optimisticTimestampMs) < OPTIMISTIC_USER_TIMESTAMP_MATCH_MS; -} - -function dropRedundantOptimisticUserMessages(sessionKey: string, messages: RawMessage[]): RawMessage[] { - const pending = _pendingOptimisticUserMessages.get(sessionKey); - if (!pending?.length) return messages; - - const pendingIds = new Set( - pending - .map((entry) => entry.message.id) - .filter((id): id is string => typeof id === 'string' && id.length > 0), - ); - if (pendingIds.size === 0) return messages; - - return messages.filter((message) => { - if (message.role !== 'user' || !message.id || !pendingIds.has(message.id)) { - return true; - } - const entry = pending.find((candidate) => candidate.message.id === message.id); - if (!entry) return true; - return !hasOptimisticServerEcho( - messages.filter((candidate) => candidate !== message), - entry.message, - entry.timestampMs, - ); - }); -} - -function upsertImageCacheEntry(filePath: string, file: Omit): void { - _imageCache.set(filePath, { ...file, filePath }); - saveImageCache(_imageCache); -} - -function withAttachedFileSource( - file: AttachedFileMeta, - source: AttachedFileMeta['source'], -): AttachedFileMeta { - return file.source ? file : { ...file, source }; -} - -/** Extract plain text from message content (string or content blocks) */ -function getMessageText(content: unknown): string { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - const parts = (content as Array<{ type?: string; text?: string }>) - .filter(b => b.type === 'text' && b.text) - .map(b => b.text!); - return compactProgressiveTextParts(parts).join('\n'); - } - return ''; -} - -function getMessageTextForFilter(msg: { content?: unknown; text?: unknown }): string { - const fromContent = getMessageText(msg.content); - if (fromContent.trim()) return fromContent; - if (typeof msg.text === 'string') return msg.text; - return ''; -} - -function getMessageStopReason(message: RawMessage | unknown): string | null { - if (!message || typeof message !== 'object') return null; - const msg = message as Record; - const rawStopReason = msg.stopReason ?? msg.stop_reason; - if (typeof rawStopReason !== 'string') return null; - const normalized = rawStopReason.trim().toLowerCase(); - return normalized || null; -} - -function getMessageErrorMessage(message: RawMessage | unknown): string | null { - if (!message || typeof message !== 'object') return null; - const msg = message as Record; - const rawError = msg.errorMessage ?? msg.error_message; - if (typeof rawError !== 'string') return null; - const normalized = rawError.trim(); - return normalized || null; -} - -function isTerminalAssistantErrorMessage(message: RawMessage | unknown): boolean { - if (!message || typeof message !== 'object') return false; - const msg = message as Record; - return msg.role === 'assistant' && getMessageStopReason(message) === 'error'; -} - -function shouldShowRunError( - sessionKey: string, - errorMessage: string | null | undefined, - dismissedBySession: Record, -): string | null { - if (!errorMessage) return null; - if (dismissedBySession[sessionKey] === errorMessage) return null; - return errorMessage; -} - -function withoutDismissedRunError( - dismissedBySession: Record, - sessionKey: string, -): Record { - if (!(sessionKey in dismissedBySession)) return dismissedBySession; - const next = { ...dismissedBySession }; - delete next[sessionKey]; - return next; -} - -/** Extract media file refs from [media attached: () | ...] patterns */ -function extractMediaRefs(text: string): Array<{ filePath: string; mimeType: string }> { - const refs: Array<{ filePath: string; mimeType: string }> = []; - const regex = /\[media attached:\s*([^\s(]+)\s*\(([^)]+)\)\s*\|[^\]]*\]/g; - let match; - while ((match = regex.exec(text)) !== null) { - refs.push({ filePath: match[1], mimeType: match[2] }); - } - return refs; -} - -/** Map common file extensions to MIME types */ -function mimeFromExtension(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - // Images - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'webp': 'image/webp', - 'bmp': 'image/bmp', - 'avif': 'image/avif', - 'svg': 'image/svg+xml', - // Documents - 'pdf': 'application/pdf', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'xls': 'application/vnd.ms-excel', - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', - 'html': 'text/html', - 'htm': 'text/html', - 'md': 'text/markdown', - 'rtf': 'application/rtf', - 'epub': 'application/epub+zip', - // Archives - 'zip': 'application/zip', - 'tar': 'application/x-tar', - 'gz': 'application/gzip', - 'rar': 'application/vnd.rar', - '7z': 'application/x-7z-compressed', - // Audio - 'mp3': 'audio/mpeg', - 'wav': 'audio/wav', - 'ogg': 'audio/ogg', - 'aac': 'audio/aac', - 'flac': 'audio/flac', - 'm4a': 'audio/mp4', - // Video - 'mp4': 'video/mp4', - 'mov': 'video/quicktime', - 'avi': 'video/x-msvideo', - 'mkv': 'video/x-matroska', - 'webm': 'video/webm', - 'm4v': 'video/mp4', - }; - return map[ext] || 'application/octet-stream'; -} - -/** Extract local file paths declared in tool call arguments. */ -function extractFilePathsFromToolArgs(args: Record): string[] { - const paths: string[] = []; - const direct = args.file_path ?? args.filePath ?? args.path ?? args.file ?? args.media ?? args.mediaUrl; - if (typeof direct === 'string' && direct.trim()) paths.push(direct.trim()); - const mediaUrls = args.mediaUrls; - if (Array.isArray(mediaUrls)) { - for (const value of mediaUrls) { - if (typeof value === 'string' && value.trim()) paths.push(value.trim()); - } - } - - const attachments = args.attachments; - if (Array.isArray(attachments)) { - for (const item of attachments) { - if (!item || typeof item !== 'object') continue; - const att = item as Record; - const filePath = att.filePath ?? att.file_path ?? att.path ?? att.file; - if (typeof filePath === 'string' && filePath.trim()) { - paths.push(filePath.trim()); - } - } - } - - return paths; -} - -function isImagePathLike(value: string): boolean { - return /\.(?:png|jpe?g|gif|webp|bmp|avif|svg)(?:$|[?#])/i.test(value.trim()); -} - -function collectMediaValues(record: Record | null | undefined): string[] { - if (!record) return []; - const values: string[] = []; - const push = (value: unknown) => { - if (typeof value === 'string' && value.trim()) values.push(value.trim()); - }; - push(record.media); - push(record.mediaUrl); - push(record.filePath); - const mediaUrls = record.mediaUrls; - if (Array.isArray(mediaUrls)) { - for (const value of mediaUrls) push(value); - } - return values; -} - -function parseMessageToolResultJson(msg: RawMessage): Record | null { - const text = getMessageText(msg.content); - if (!text.trim()) return null; - try { - const parsed = JSON.parse(text); - return parsed && typeof parsed === 'object' ? parsed as Record : null; - } catch { - return null; - } -} - -type MessageToolDelivery = { - files: AttachedFileMeta[]; - text: string; - internalUi: boolean; -}; - -function collectMessageToolDelivery(msg: RawMessage): MessageToolDelivery | null { - if (!isToolResultRole(msg.role)) return null; - if (msg.toolName !== 'message') return null; - - const details = msg.details && typeof msg.details === 'object' - ? msg.details as Record - : parseMessageToolResultJson(msg); - if (!details) return null; - if (String(details.status ?? '').toLowerCase() === 'error') return null; - - const sourceReply = details.sourceReply && typeof details.sourceReply === 'object' - ? details.sourceReply as Record - : null; - const seen = new Set(); - const files: AttachedFileMeta[] = []; - - for (const media of [...collectMediaValues(details), ...collectMediaValues(sourceReply)]) { - if (seen.has(media)) continue; - seen.add(media); - if (media.startsWith('/api/chat/media/')) { - files.push({ - fileName: 'image', - mimeType: 'image/png', - fileSize: 0, - preview: null, - gatewayUrl: media, - source: 'gateway-media', - }); - continue; - } - if (!isImagePathLike(media)) continue; - files.push(makeAttachedFile({ filePath: media, mimeType: mimeFromExtension(media) }, 'tool-result')); - } - - const sourceReplyText = sourceReply?.text; - const detailsMessage = details.message; - return { - files, - text: typeof sourceReplyText === 'string' && sourceReplyText.trim() - ? sourceReplyText.trim() - : typeof detailsMessage === 'string' ? detailsMessage.trim() : '', - internalUi: details.sourceReplySink === 'internal-ui' - || details.sourceReplyDeliveryMode === 'message_tool_only', - }; -} - -function createInternalUiDeliveryReply(msg: RawMessage, delivery: MessageToolDelivery): RawMessage | null { - if (!delivery.internalUi || (!delivery.text && delivery.files.length === 0)) return null; - const idBase = msg.id || msg.toolCallId; - return { - role: 'assistant', - content: delivery.text ? [{ type: 'text', text: delivery.text }] : [], - timestamp: msg.timestamp, - ...(idBase ? { id: `${idBase}:source-reply` } : {}), - _attachedFiles: delivery.files, - }; -} - -/** - * Surface user-facing attachments declared in assistant tool calls (e.g. - * `message` tool `attachments: [{ filePath }]`) on the calling turn itself. - */ -function enrichWithToolCallAttachments(messages: RawMessage[]): RawMessage[] { - const internalUiDeliveryToolCallIds = new Set( - messages.flatMap((message) => { - const delivery = collectMessageToolDelivery(message); - return delivery?.internalUi && delivery.files.length > 0 && message.toolCallId - ? [message.toolCallId] - : []; - }), - ); - - return messages.map((msg) => { - if (msg.role !== 'assistant') return msg; - - const attachmentPaths = new Set(); - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if (block.type !== 'tool_use' && block.type !== 'toolCall') continue; - if (block.name === 'message' && block.id && internalUiDeliveryToolCallIds.has(block.id)) continue; - const args = (block.input ?? block.arguments) as Record | undefined; - if (!args) continue; - for (const filePath of extractFilePathsFromToolArgs(args)) { - attachmentPaths.add(filePath); - } - } - } - - const msgAny = msg as unknown as Record; - const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; - if (Array.isArray(toolCalls)) { - for (const tc of toolCalls as Array>) { - if (typeof tc.id === 'string' && internalUiDeliveryToolCallIds.has(tc.id)) continue; - const fn = (tc.function ?? tc) as Record; - let args: Record | undefined; - try { - args = typeof fn.arguments === 'string' - ? JSON.parse(fn.arguments) - : (fn.arguments ?? fn.input) as Record; - } catch { /* ignore */ } - if (!args) continue; - for (const filePath of extractFilePathsFromToolArgs(args)) { - attachmentPaths.add(filePath); - } - } - } - - if (attachmentPaths.size === 0) return msg; - - const existingPaths = new Set( - (msg._attachedFiles || []).map((file) => file.filePath).filter(Boolean), - ); - const newFiles = [...attachmentPaths] - .filter((filePath) => !existingPaths.has(filePath)) - .map((filePath) => makeAttachedFile({ filePath, mimeType: mimeFromExtension(filePath) }, 'tool-result')); - - if (newFiles.length === 0) return msg; - return { - ...msg, - _attachedFiles: [...(msg._attachedFiles || []), ...newFiles], - }; - }); -} - -const DIRECTORY_MIME_TYPE = 'application/x-directory'; - -function trimPathTerminators(filePath: string): string { - return filePath.replace(/[,。;;,.!?]+$/u, ''); -} - -type MarkdownImageRef = - | { filePath: string; mimeType: string; fileName: string } - | { gatewayUrl: string; mimeType: string; fileName: string; source: 'gateway-media' }; - -/** Extract image targets from markdown `![alt](target)` in assistant text. */ -function extractMarkdownImageRefs(text: string): MarkdownImageRef[] { - if (!text) return []; - const refs: MarkdownImageRef[] = []; - const seen = new Set(); - const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; - let match: RegExpExecArray | null; - while ((match = markdownImageRegex.exec(text)) !== null) { - const alt = match[1]?.trim() || 'image'; - let target = match[2]?.trim() ?? ''; - if (!target) continue; - if (target.startsWith('file://')) { - target = decodeURIComponent(target.replace(/^file:\/\//, '')); - } - if (target.startsWith('/api/chat/media/')) { - if (seen.has(target)) continue; - seen.add(target); - refs.push({ - gatewayUrl: target, - mimeType: 'image/png', - fileName: alt, - source: 'gateway-media', - }); - continue; - } - const normalizedPath = trimPathTerminators(target); - if (!normalizedPath.startsWith('/') && !normalizedPath.startsWith('~/') && !/^[A-Za-z]:\\/.test(normalizedPath)) continue; - if (seen.has(normalizedPath)) continue; - seen.add(normalizedPath); - refs.push({ - filePath: normalizedPath, - mimeType: mimeFromExtension(normalizedPath), - fileName: alt, - }); - } - return refs; -} - -/** - * Extract raw file paths from message text. - * Detects absolute paths (Unix: / or ~/, Windows: C:\ etc.) ending with common file extensions. - * Handles both image and non-image files, consistent with channel push message behavior. - * - * Also recognises the `MEDIA:` / `media:` prefix the OpenClaw runtime - * emits for produced artifacts (e.g. - * `MEDIA:/Users/me/.openclaw/media/outbound/report.xlsx`) — without this - * the leading colon trips the URL guard below and the file goes unsurfaced. - */ -function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: string }> { - const refs: Array<{ filePath: string; mimeType: string }> = []; - const seen = new Set(); - const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|html?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v'; - // Tagged media references (MEDIA:/path, media:~/path, MEDIA:C:\path, ...). The agent - // runtime uses this prefix as an explicit "this is an artifact" marker, - // so we want them recognised even though the leading colon would - // normally look like a URL scheme. After matching we punch the entire - // `MEDIA:` span out of the working text so the generic unix - // regex below doesn't double-count the bare `/path` suffix. - // The character class deliberately allows ASCII spaces inside the path so - // that macOS' default screenshot filename ("截屏 2026-05-06 17.46.51.png") - // and other space-containing paths the agent emits with the explicit - // `MEDIA:` marker still resolve. Newline and quote characters remain - // path terminators so we don't accidentally swallow trailing prose. - // The non-greedy `*?` anchored to `\.` keeps the match minimal so - // multiple `MEDIA:` markers in one paragraph still match independently. - const taggedRegex = new RegExp(`(?` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g'); - let workingText = text; - let taggedMatch: RegExpExecArray | null; - while ((taggedMatch = taggedRegex.exec(text)) !== null) { - const p = taggedMatch[1]; - if (p && !seen.has(p)) { - seen.add(p); - refs.push({ filePath: p, mimeType: mimeFromExtension(p) }); - } - // Mask the matched span so subsequent regexes can't re-discover the - // same path (e.g. `/two.xlsx` from `MEDIA:~/two.xlsx`). - const start = taggedMatch.index; - const end = start + taggedMatch[0].length; - workingText = workingText.slice(0, start) + ' '.repeat(end - start) + workingText.slice(end); - } - // Unix absolute paths (/... or ~/...) — lookbehind rejects mid-token slashes - // (e.g. "path/to/file.mp4", "https://example.com/file.mp4") - const unixRegex = new RegExp(`(?]*?\\.(?:${exts}))`, 'gi'); - // Windows absolute paths (C:\... D:\...) — lookbehind rejects drive letter glued to a word - const winRegex = new RegExp(`(?]*?\\.(?:${exts}))`, 'gi'); - // OpenClaw skill directories do not have file extensions, but they are - // user-facing artifacts that should render as clickable folder cards. - const skillPathBoundary = '(?=$|\\s|[\\x5b\\x5d"\'`(),<>,。;;,.!?])'; - const skillPathPart = '[^\\\\/\\s\\n"\'`()\\x5b\\x5d,<>]+'; - const skillPathTail = '[^\\s\\n"\'`()\\x5b\\x5d,<>]*?'; - const skillDirRegex = new RegExp( - `(?//full', - // mimeType, width, height, alt, openUrl }`. The URL is relative - // to the Gateway HTTP server which the renderer cannot reach directly - // (CORS / env drift). We surface it as an `_attachedFiles` entry whose - // preview is filled in later by `loadMissingPreviews` -> Main proxy. - else if (block.url) { - const mimeType = block.mimeType || 'image/jpeg'; - const fileName = typeof block.alt === 'string' && block.alt - ? block.alt - : 'image'; - files.push({ - fileName, - mimeType, - fileSize: 0, - preview: null, - gatewayUrl: block.url, - source: 'gateway-media', - }); - } - } - // Recurse into tool_result content blocks - if ((block.type === 'tool_result' || block.type === 'toolResult') && block.content) { - files.push(...extractImagesAsAttachedFiles(block.content)); - } - } - return files; -} - -/** - * Build an AttachedFileMeta entry for a file ref, using cache if available. - */ -function makeAttachedFile( - ref: { filePath: string; mimeType: string }, - source: AttachedFileMeta['source'] = 'message-ref', -): AttachedFileMeta { - const cached = _imageCache.get(ref.filePath); - if (cached) return { ...cached, filePath: ref.filePath, source }; - const fileName = ref.filePath.split(/[\\/]/).pop() || 'file'; - return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath, source }; -} - -/** - * Extract file path from a tool call's arguments by toolCallId. - * Searches common argument names: file_path, filePath, path, file. - */ -function getToolCallFilePath(msg: RawMessage, toolCallId: string): string | undefined { - if (!toolCallId) return undefined; - - // Anthropic/normalized format — toolCall blocks in content array - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id === toolCallId) { - const args = (block.input ?? block.arguments) as Record | undefined; - if (args) { - const paths = extractFilePathsFromToolArgs(args); - if (paths[0]) return paths[0]; - } - } - } - } - - // OpenAI format — tool_calls array on the message itself - const msgAny = msg as unknown as Record; - const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; - if (Array.isArray(toolCalls)) { - for (const tc of toolCalls as Array>) { - if (tc.id !== toolCallId) continue; - const fn = (tc.function ?? tc) as Record; - let args: Record | undefined; - try { - args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : (fn.arguments ?? fn.input) as Record; - } catch { /* ignore */ } - if (args) { - const paths = extractFilePathsFromToolArgs(args); - if (paths[0]) return paths[0]; - } - } - } - - return undefined; -} - -/** - * Collect all tool call file paths from a message into a Map. - */ -function collectToolCallPaths(msg: RawMessage, paths: Map): void { - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id) { - const args = (block.input ?? block.arguments) as Record | undefined; - if (args) { - const filePaths = extractFilePathsFromToolArgs(args); - if (filePaths[0]) paths.set(block.id, filePaths[0]); - } - } - } - } - const msgAny = msg as unknown as Record; - const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; - if (Array.isArray(toolCalls)) { - for (const tc of toolCalls as Array>) { - const id = typeof tc.id === 'string' ? tc.id : ''; - if (!id) continue; - const fn = (tc.function ?? tc) as Record; - let args: Record | undefined; - try { - args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : (fn.arguments ?? fn.input) as Record; - } catch { /* ignore */ } - if (args) { - const filePaths = extractFilePathsFromToolArgs(args); - if (filePaths[0]) paths.set(id, filePaths[0]); - } - } - } -} - -function assistantHasToolCallId(msg: RawMessage, toolCallId: string): boolean { - if (!toolCallId) return false; - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id === toolCallId) { - return true; - } - } - } - const msgAny = msg as unknown as Record; - const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; - return Array.isArray(toolCalls) && toolCalls.some((tc) => - tc && typeof tc === 'object' && (tc as Record).id === toolCallId, - ); -} - -/** - * Before filtering tool_result messages from history, scan them for any file/image - * content and attach those to the immediately following assistant message. - * This mirrors channel push message behavior where tool outputs surface files to the UI. - * Handles: - * - Image content blocks (base64 / url) - * - [media attached: path (mime) | path] text patterns in tool result output - * - Raw file paths in tool result text - */ -function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] { - const pending: AttachedFileMeta[] = []; - const toolCallPaths = new Map(); - const enriched: RawMessage[] = []; - - for (const msg of messages) { - // Track file paths from assistant tool call arguments for later matching - if (msg.role === 'assistant') { - collectToolCallPaths(msg, toolCallPaths); - } - - if (isToolResultRole(msg.role)) { - const delivery = collectMessageToolDelivery(msg); - const deliveredFiles = delivery?.files ?? []; - const internalUiReply = delivery ? createInternalUiDeliveryReply(msg, delivery) : null; - if (internalUiReply) { - enriched.push(msg, internalUiReply); - continue; - } - if (deliveredFiles.length > 0) { - let attachIndex = -1; - if (msg.toolCallId) { - for (let index = enriched.length - 1; index >= 0; index -= 1) { - const candidate = enriched[index]; - if (candidate?.role !== 'assistant') continue; - if (assistantHasToolCallId(candidate, msg.toolCallId)) { - attachIndex = index; - break; - } - } - } - - if (attachIndex >= 0) { - const target = enriched[attachIndex]!; - const existingKeys = new Set( - (target._attachedFiles || []).map(file => file.filePath || file.gatewayUrl).filter(Boolean), - ); - const newFiles = deliveredFiles.filter(file => { - const key = file.filePath || file.gatewayUrl; - return !key || !existingKeys.has(key); - }); - if (newFiles.length > 0) { - enriched[attachIndex] = { - ...target, - _attachedFiles: [...(target._attachedFiles || []), ...newFiles], - }; - } - } else { - pending.push(...deliveredFiles); - } - } - - const matchedPath = msg.toolCallId ? toolCallPaths.get(msg.toolCallId) : undefined; - - // 1. Image/file content blocks in the structured content array. - // Images embedded inside a tool result are the model's vision data - // (e.g. `read /tmp/foo.png` re-encoded as JPEG so the model can - // "see" the file) — they are NOT user-facing artifacts. The agent - // surfaces user-facing images through `MEDIA:/path` text + the - // Gateway's `assistant-media` injection. - const imageFiles = extractImagesAsAttachedFiles(msg.content) - .filter(file => !file.mimeType.startsWith('image/')); - if (matchedPath) { - for (const f of imageFiles) { - if (!f.filePath) { - f.filePath = matchedPath; - f.fileName = matchedPath.split(/[\\/]/).pop() || 'image'; - } - } - } - pending.push(...imageFiles.map((file) => withAttachedFileSource(file, 'tool-result'))); - - // 2. [media attached: ...] patterns in tool result text output - const text = getMessageText(msg.content); - if (text) { - const mediaRefs = extractMediaRefs(text); - const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); - for (const ref of mediaRefs) { - pending.push(makeAttachedFile(ref, 'tool-result')); - } - // 3. Raw NON-image file paths in tool result text (documents, - // audio, video, ...). Image paths from intermediate tool stdout - // (`ls -la *.png`, `sips ... && ls`, `file /tmp/x.png`, etc.) - // are deliberately ignored — see comment on Path 1. - for (const ref of extractRawFilePaths(text)) { - if (mediaRefPaths.has(ref.filePath)) continue; - if (ref.mimeType.startsWith('image/')) continue; - pending.push(makeAttachedFile(ref, 'tool-result')); - } - } - - enriched.push(msg); // will be filtered later - continue; - } - - if (msg.role === 'assistant' && pending.length > 0) { - // Internal-only turns (NO_REPLY, interim narration, ...) must not consume - // pending attachments — the next visible assistant reply should get them. - if (isInternalMessage(msg) && !messageHasToolUse(msg)) { - enriched.push(msg); - continue; - } - const toAttach = pending.splice(0); - // Deduplicate against files already on the assistant message - const existingKeys = new Set( - (msg._attachedFiles || []).map(f => f.filePath || f.gatewayUrl).filter(Boolean), - ); - const newFiles = toAttach.filter(f => { - const key = f.filePath || f.gatewayUrl; - return !key || !existingKeys.has(key); - }); - if (newFiles.length === 0) { - enriched.push(msg); - continue; - } - enriched.push({ - ...msg, - _attachedFiles: [...(msg._attachedFiles || []), ...newFiles], - }); - continue; - } - - enriched.push(msg); - } - - return enriched; -} - -/** - * Restore _attachedFiles for messages loaded from history. - * Handles: - * 1. [media attached: path (mime) | path] patterns (attachment-button flow) - * 2. Raw image file paths typed in message text (e.g. /Users/.../image.png) - * Uses local cache for previews when available; missing previews are loaded async. - */ -function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] { - // Pre-compute, per index, whether the *next* assistant message is a - // Gateway-injected `assistant-media` bubble (i.e. has at least one - // `image` content block carrying a flat URL). When that bubble exists, - // the canonical user-facing rendering of the artifact is the bubble - // itself — anything the agent emitted via `MEDIA:/path` in its prior - // text turn would just duplicate the same image, so image-typed raw - // refs on that prior message are dropped here. - const nextHasGatewayMediaBubble = messages.map((_, idx) => { - const next = messages[idx + 1]; - if (!next || next.role !== 'assistant') return false; - return extractImagesAsAttachedFiles(next.content).some(f => f.gatewayUrl); - }); - - return messages.map((msg, idx) => { - // Only process user and assistant messages. Messages may already carry - // attachments from tool-result enrichment; still merge in raw paths from - // the visible assistant text so `/path/to/report.xlsx` becomes a card. - if (msg.role !== 'user' && msg.role !== 'assistant') return msg; - const text = getMessageText(msg.content); - - // Path 0: Gateway-injected outgoing media — `image` content blocks with - // a flat `url` field (e.g. `/api/chat/media/outgoing///full`). - // The renderer cannot fetch the URL directly, so we surface it as an - // `_attachedFiles` entry whose preview is filled in later by - // `loadMissingPreviews` -> Main `media:getThumbnails` (which dereferences - // the URL to the original file in `~/.openclaw/media/outgoing/`). - const gatewayMediaFiles: AttachedFileMeta[] = msg.role === 'assistant' - ? extractImagesAsAttachedFiles(msg.content).filter(file => file.gatewayUrl) - : []; - - // Path 1: [media attached: path (mime) | path] — guaranteed format from attachment button - const mediaRefs = extractMediaRefs(text); - const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); - - // Path 2: Raw file paths. - // For assistant messages: scan own text AND the nearest preceding user message text, - // but only for non-tool-only assistant messages (i.e. the final answer turn). - // Tool-only messages (thinking + tool calls) should not show file previews — those - // belong to the final answer message that comes after the tool results. - // User messages never get raw-path previews so the image is not shown twice. - let rawRefs: Array<{ filePath: string; mimeType: string }> = []; - if (msg.role === 'assistant' && !isToolOnlyMessage(msg)) { - // Own text - rawRefs = extractRawFilePaths(text).filter(r => !mediaRefPaths.has(r.filePath)); - const rawPathSet = new Set(rawRefs.map((ref) => ref.filePath)); - for (const ref of extractMarkdownImageRefs(text)) { - if ('filePath' in ref && !mediaRefPaths.has(ref.filePath) && !rawPathSet.has(ref.filePath)) { - rawPathSet.add(ref.filePath); - rawRefs.push({ filePath: ref.filePath, mimeType: ref.mimeType }); - } - } - - // Nearest preceding user message text (look back up to 5 messages) - const seenPaths = new Set(rawRefs.map(r => r.filePath)); - for (let i = idx - 1; i >= Math.max(0, idx - 5); i--) { - const prev = messages[i]; - if (!prev) break; - if (prev.role === 'user') { - const prevText = getMessageText(prev.content); - for (const ref of extractRawFilePaths(prevText)) { - if (!mediaRefPaths.has(ref.filePath) && !seenPaths.has(ref.filePath)) { - seenPaths.add(ref.filePath); - rawRefs.push(ref); - } - } - break; // only use the nearest user message - } - } - } - - // Dedup vs Gateway-injected bubble: when the very next assistant - // message is an `assistant-media` bubble, drop image-typed raw refs - // on *this* message — the bubble already covers the artifact. - if (msg.role === 'assistant' && nextHasGatewayMediaBubble[idx]) { - rawRefs = rawRefs.filter(r => !r.mimeType.startsWith('image/')); - } - - const allRefs = [...mediaRefs, ...rawRefs]; - const markdownImageRefs = msg.role === 'assistant' && !isToolOnlyMessage(msg) - ? extractMarkdownImageRefs(text) - : []; - if ( - allRefs.length === 0 - && gatewayMediaFiles.length === 0 - && markdownImageRefs.length === 0 - ) { - return msg; - } - - const existingFiles = msg._attachedFiles || []; - const existingPaths = new Set(existingFiles.map(file => file.filePath).filter(Boolean)); - const existingGatewayUrls = new Set( - existingFiles.map(file => file.gatewayUrl).filter(Boolean) as string[], - ); - const files: AttachedFileMeta[] = allRefs - .filter(ref => !existingPaths.has(ref.filePath)) - .map(ref => { - const cached = _imageCache.get(ref.filePath); - if (cached) return { ...cached, filePath: ref.filePath, source: 'message-ref' }; - const fileName = ref.filePath.split(/[\\/]/).pop() || 'file'; - return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath, source: 'message-ref' }; - }); - const dedupedGatewayMedia = gatewayMediaFiles.filter( - file => file.gatewayUrl && !existingGatewayUrls.has(file.gatewayUrl), - ); - const markdownGatewayMedia: AttachedFileMeta[] = markdownImageRefs - .filter((ref): ref is Extract => 'gatewayUrl' in ref) - .filter((ref) => ref.gatewayUrl && !existingGatewayUrls.has(ref.gatewayUrl)) - .map((ref) => ({ - fileName: ref.fileName, - mimeType: ref.mimeType, - fileSize: 0, - preview: null, - gatewayUrl: ref.gatewayUrl, - source: 'gateway-media' as const, - })); - if (files.length === 0 && dedupedGatewayMedia.length === 0 && markdownGatewayMedia.length === 0) return msg; - return { ...msg, _attachedFiles: [...existingFiles, ...files, ...dedupedGatewayMedia, ...markdownGatewayMedia] }; - }); -} - -type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string }; - -const IMAGE_PREVIEW_RETRY_DELAYS_MS = [300, 900, 1800]; - -function waitForPreviewRetry(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// Collect all image refs that need previews. The IPC handler accepts: -// - { filePath, mimeType } — local on-disk files -// - { gatewayUrl, mimeType } — Gateway-injected outgoing media; the -// handler resolves the URL to a local file -// via `~/.openclaw/media/outgoing/records/`. -// We use `filePath || gatewayUrl` as the dedupe / lookup key on the way -// back; a file always carries at most one of the two. -function collectMissingPreviewRefs(messages: RawMessage[]): PreviewRef[] { - const needPreview: PreviewRef[] = []; - const seenKeys = new Set(); - - for (const msg of messages) { - if (!msg._attachedFiles) continue; - - // Path 1: files with explicit filePath OR gatewayUrl - for (const file of msg._attachedFiles) { - const key = file.filePath || file.gatewayUrl; - if (!key || seenKeys.has(key)) continue; - // Images: need preview. Non-images: need file size (for FileCard display). - const needsLoad = file.mimeType.startsWith('image/') - ? !file.preview && file.previewStatus !== 'unavailable' - : file.fileSize === 0; - if (!needsLoad) continue; - seenKeys.add(key); - if (file.filePath) { - needPreview.push({ filePath: file.filePath, mimeType: file.mimeType }); - } else if (file.gatewayUrl) { - needPreview.push({ gatewayUrl: file.gatewayUrl, mimeType: file.mimeType }); - } - } - - // Path 2: [media attached: ...] patterns (legacy — in case filePath wasn't stored) - if (msg.role === 'user') { - const text = getMessageText(msg.content); - const refs = extractMediaRefs(text); - for (let i = 0; i < refs.length; i++) { - const file = msg._attachedFiles[i]; - const ref = refs[i]; - if (!file || !ref || seenKeys.has(ref.filePath)) continue; - const needsLoad = ref.mimeType.startsWith('image/') - ? !file.preview && file.previewStatus !== 'unavailable' - : file.fileSize === 0; - if (needsLoad) { - seenKeys.add(ref.filePath); - needPreview.push({ filePath: ref.filePath, mimeType: ref.mimeType }); - } - } - } - } - - return needPreview; -} - -function applyPreviewResults( - messages: RawMessage[], - thumbnails: Record, -): boolean { - let updated = false; - - for (const msg of messages) { - if (!msg._attachedFiles) continue; - - // Update files that have filePath OR gatewayUrl - for (const file of msg._attachedFiles) { - const key = file.filePath || file.gatewayUrl; - if (!key) continue; - const thumb = thumbnails[key]; - if (thumb && (thumb.preview || thumb.fileSize)) { - if (thumb.preview) file.preview = thumb.preview; - if (thumb.fileSize) file.fileSize = thumb.fileSize; - delete file.previewStatus; - // Only persist local-path entries to the localStorage cache. - // Gateway outgoing URLs are tied to a specific session/attachment - // id and can be stale across runs, so caching is harmful. - if (file.filePath) { - _imageCache.set(file.filePath, { ...file }); - } - updated = true; - } - } - - // Legacy: update by index for [media attached: ...] refs - if (msg.role === 'user') { - const text = getMessageText(msg.content); - const refs = extractMediaRefs(text); - for (let i = 0; i < refs.length; i++) { - const file = msg._attachedFiles[i]; - const ref = refs[i]; - if (!file || !ref || file.filePath) continue; // skip if already handled via filePath - const thumb = thumbnails[ref.filePath]; - if (thumb && (thumb.preview || thumb.fileSize)) { - if (thumb.preview) file.preview = thumb.preview; - if (thumb.fileSize) file.fileSize = thumb.fileSize; - delete file.previewStatus; - _imageCache.set(ref.filePath, { ...file }); - updated = true; - } - } - } - } - - if (updated) saveImageCache(_imageCache); - return updated; -} - -function markMissingImagePreviewsUnavailable(messages: RawMessage[]): boolean { - let updated = false; - for (const msg of messages) { - if (!msg._attachedFiles) continue; - for (const file of msg._attachedFiles) { - if (!file.mimeType.startsWith('image/')) continue; - if (file.preview || file.previewStatus === 'unavailable') continue; - if (!file.filePath && !file.gatewayUrl) continue; - file.previewStatus = 'unavailable'; - updated = true; - } - } - return updated; -} - -/** - * Async: load missing previews from disk via IPC for messages that have - * _attachedFiles with null previews. Updates messages in-place and triggers re-render. - * Handles both [media attached: ...] patterns and raw filePath entries. - */ -async function loadMissingPreviews(messages: RawMessage[]): Promise { - let updatedAny = false; - let attempt = 0; - - while (true) { - const needPreview = collectMissingPreviewRefs(messages); - if (needPreview.length === 0) return updatedAny; - if (attempt > 0) { - const delayMs = IMAGE_PREVIEW_RETRY_DELAYS_MS[attempt - 1]; - if (delayMs) await waitForPreviewRetry(delayMs); - } - - try { - const thumbnails = await hostApi.media.thumbnails({ - paths: needPreview, - }); - if (applyPreviewResults(messages, thumbnails)) { - updatedAny = true; - } - } catch (err) { - console.warn('[loadMissingPreviews] Failed:', err); - return updatedAny; - } - - if (!collectMissingPreviewRefs(messages).some((ref) => ref.mimeType.startsWith('image/'))) { - return updatedAny; - } - if (attempt >= IMAGE_PREVIEW_RETRY_DELAYS_MS.length) { - return markMissingImagePreviewsUnavailable(messages) || updatedAny; - } - attempt += 1; - } -} - -function getCanonicalPrefixFromSessions(sessions: ChatSession[]): string | null { - const canonical = sessions.find((s) => s.key.startsWith('agent:'))?.key; - if (!canonical) return null; - const parts = canonical.split(':'); - if (parts.length < 2) return null; - return `${parts[0]}:${parts[1]}`; -} - -function isToolOnlyMessage(message: RawMessage | undefined): boolean { - if (!message) return false; - if (isToolResultRole(message.role)) return true; - - const msg = message as unknown as Record; - const content = message.content; - - // Check OpenAI-format tool_calls field (real-time streaming from OpenAI-compatible models) - const toolCalls = msg.tool_calls ?? msg.toolCalls; - const hasOpenAITools = Array.isArray(toolCalls) && toolCalls.length > 0; - - if (!Array.isArray(content)) { - // Content is not an array — check if there's OpenAI-format tool_calls - if (hasOpenAITools) { - // Has tool calls but content might be empty/string — treat as tool-only - // if there's no meaningful text content - const textContent = typeof content === 'string' ? content.trim() : ''; - return textContent.length === 0; - } - return false; - } - - let hasTool = hasOpenAITools; - let hasText = false; - let hasNonToolContent = false; - - for (const block of content as ContentBlock[]) { - if (block.type === 'tool_use' || block.type === 'tool_result' || block.type === 'toolCall' || block.type === 'toolResult') { - hasTool = true; - continue; - } - if (block.type === 'text' && block.text && block.text.trim()) { - hasText = true; - continue; - } - // Only actual image output disqualifies a tool-only message. - // Thinking blocks are internal reasoning that can accompany tool_use — they - // should NOT prevent the message from being treated as an intermediate tool step. - if (block.type === 'image') { - hasNonToolContent = true; - } - } - - return hasTool && !hasText && !hasNonToolContent; -} - -function isToolResultRole(role: unknown): boolean { - if (!role) return false; - const normalized = String(role).toLowerCase(); - return normalized === 'toolresult' || normalized === 'tool_result'; -} - -function messageHasToolUse(msg: { role?: unknown; content?: unknown; tool_calls?: unknown; toolCalls?: unknown }): boolean { - if (msg.role !== 'assistant') return false; - if (Array.isArray(msg.content)) { - const blocks = msg.content as ContentBlock[]; - if (blocks.some((block) => block.type === 'tool_use' || block.type === 'toolCall')) { - return true; - } - } - const toolCalls = msg.tool_calls ?? msg.toolCalls; - return Array.isArray(toolCalls) && toolCalls.length > 0; -} - -/** True for internal plumbing messages that should never be shown in the UI. */ -function isInternalMessage(msg: { role?: unknown; content?: unknown; text?: unknown }): boolean { - if (msg.role === 'system') return true; - const text = getMessageTextForFilter(msg); - if (msg.role === 'assistant') { - if (isInternalAssistantReplyText(text)) return true; - if (isGeneratingStatusNarration(text)) return true; - if (!text.trim() && Array.isArray(msg.content)) { - const blocks = msg.content as ContentBlock[]; - const hasThinking = blocks.some((block) => block.type === 'thinking' && block.thinking?.trim()); - const hasVisibleText = blocks.some((block) => block.type === 'text' && block.text?.trim()); - if (hasThinking && !hasVisibleText) return true; - } - } - if (msg.role === 'user' && /^\[OpenClaw heartbeat poll\]\s*$/i.test(text.trim())) return true; - // Runtime system injections: these arrive as user or assistant-role messages - // but are internal plumbing (exec results, async-command notices, time pings, etc.) - if ((msg.role === 'user' || msg.role === 'assistant') && isRuntimeSystemInjection(text)) return true; - return false; -} - -/** - * History filtering must keep assistant tool-call turns even when their visible - * text is internal narration (e.g. "生成中,稍等" + `image_generate`). Those - * turns power the execution graph and run lifecycle detection. - */ -function shouldDropMessageFromHistory(msg: { role?: unknown; content?: unknown; text?: unknown; tool_calls?: unknown; toolCalls?: unknown }): boolean { - if (isToolResultRole(msg.role)) return true; - if (messageHasToolUse(msg)) return false; - return isInternalMessage(msg); -} - -/** - * Detect runtime-injected system messages that should be hidden from the chat UI. - * These are injected by the OpenClaw runtime as user-role messages and include: - * - "System (untrusted): ..." — exec results, tool output, etc. - * - "An async command you ran earlier has completed" — async completion notices - * - "Current time: ..." followed by nothing else — periodic heartbeat time pings - * - "Handle the result internally. Do not relay it to the user" — internal directives - */ -function isRuntimeSystemInjection(text: string): boolean { - if (!text) return false; - const normalized = text.trim(); - // "System (untrusted): ..." at the start (with optional leading whitespace) - if (/^\s*System\s*\(untrusted\)\s*:/i.test(normalized)) return true; - - // Async command completion notice + internal relay directive commonly arrive together. - // Require both markers to avoid hiding normal conversational text that quotes one phrase. - if ( - /An async command you ran earlier has completed/i.test(normalized) - && /Do not relay it to the user unless explicitly requested/i.test(normalized) - ) { - return true; - } - - if (/^\[Inter-session message\]/i.test(normalized)) return true; - - if (isOpenClawRuntimeEventPrompt(normalized)) return true; - - // Standalone time injection - if ( - /^\s*Current time\s*:/i.test(normalized) - && /^\s*Current time\s*:[^\n]*\/\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+UTC\s*$/i.test(normalized) - ) { - return true; - } - return false; -} - -function extractTextFromContent(content: unknown): string { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - const parts: string[] = []; - for (const block of content as ContentBlock[]) { - if (block.type === 'text' && block.text) { - parts.push(block.text); - } - } - return compactProgressiveTextParts(parts).join('\n'); -} - -function summarizeToolOutput(text: string): string | undefined { - const trimmed = text.trim(); - if (!trimmed) return undefined; - const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - if (lines.length === 0) return undefined; - const summaryLines = lines.slice(0, 2); - let summary = summaryLines.join(' / '); - if (summary.length > 160) { - summary = `${summary.slice(0, 157)}...`; - } - return summary; -} - -function normalizeToolStatus(rawStatus: unknown, fallback: 'running' | 'completed'): ToolStatus['status'] { - const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : ''; - if (status === 'error' || status === 'failed') return 'error'; - if (status === 'completed' || status === 'success' || status === 'done') return 'completed'; - return fallback; -} - -function parseDurationMs(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - const parsed = typeof value === 'string' ? Number(value) : NaN; - return Number.isFinite(parsed) ? parsed : undefined; -} - -function extractToolUseUpdates(message: unknown): ToolStatus[] { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - const updates: ToolStatus[] = []; - - // Path 1: Anthropic/normalized format — tool blocks inside content array - const content = msg.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if ((block.type !== 'tool_use' && block.type !== 'toolCall') || !block.name) continue; - updates.push({ - id: block.id || block.name, - toolCallId: block.id, - name: block.name, - status: 'running', - updatedAt: Date.now(), - }); - } - } - - // Path 2: OpenAI format — tool_calls array on the message itself - if (updates.length === 0) { - const toolCalls = msg.tool_calls ?? msg.toolCalls; - if (Array.isArray(toolCalls)) { - for (const tc of toolCalls as Array>) { - const fn = (tc.function ?? tc) as Record; - const name = typeof fn.name === 'string' ? fn.name : ''; - if (!name) continue; - const id = typeof tc.id === 'string' ? tc.id : name; - updates.push({ - id, - toolCallId: typeof tc.id === 'string' ? tc.id : undefined, - name, - status: 'running', - updatedAt: Date.now(), - }); - } - } - } - - return updates; -} - -function extractToolResultBlocks(message: unknown, eventState: string): ToolStatus[] { - if (!message || typeof message !== 'object') return []; - const msg = message as Record; - const content = msg.content; - if (!Array.isArray(content)) return []; - - const updates: ToolStatus[] = []; - for (const block of content as ContentBlock[]) { - if (block.type !== 'tool_result' && block.type !== 'toolResult') continue; - const outputText = extractTextFromContent(block.content ?? block.text ?? ''); - const summary = summarizeToolOutput(outputText); - updates.push({ - id: block.id || block.name || 'tool', - toolCallId: block.id, - name: block.name || block.id || 'tool', - status: normalizeToolStatus(undefined, eventState === 'delta' ? 'running' : 'completed'), - summary, - updatedAt: Date.now(), - }); - } - - return updates; -} - -function extractToolResultUpdate(message: unknown, eventState: string): ToolStatus | null { - if (!message || typeof message !== 'object') return null; - const msg = message as Record; - const role = typeof msg.role === 'string' ? msg.role.toLowerCase() : ''; - if (!isToolResultRole(role)) return null; - - const toolName = typeof msg.toolName === 'string' ? msg.toolName : (typeof msg.name === 'string' ? msg.name : ''); - const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined; - const details = (msg.details && typeof msg.details === 'object') ? msg.details as Record : undefined; - const rawStatus = (msg.status ?? details?.status); - const fallback = eventState === 'delta' ? 'running' : 'completed'; - const status = normalizeToolStatus(rawStatus, fallback); - const durationMs = parseDurationMs(details?.durationMs ?? details?.duration ?? (msg as Record).durationMs); - - const outputText = (details && typeof details.aggregated === 'string') - ? details.aggregated - : extractTextFromContent(msg.content); - const summary = summarizeToolOutput(outputText) ?? summarizeToolOutput(String(details?.error ?? msg.error ?? '')); - - const name = toolName || toolCallId || 'tool'; - const id = toolCallId || name; - - return { - id, - toolCallId, - name, - status, - durationMs, - summary, - updatedAt: Date.now(), - }; -} - -function mergeToolStatus(existing: ToolStatus['status'], incoming: ToolStatus['status']): ToolStatus['status'] { - const order: Record = { running: 0, completed: 1, error: 2 }; - return order[incoming] >= order[existing] ? incoming : existing; -} - -function upsertToolStatuses(current: ToolStatus[], updates: ToolStatus[]): ToolStatus[] { - if (updates.length === 0) return current; - const next = [...current]; - for (const update of updates) { - const key = update.toolCallId || update.id || update.name; - if (!key) continue; - const index = next.findIndex((tool) => (tool.toolCallId || tool.id || tool.name) === key); - if (index === -1) { - next.push(update); - continue; - } - const existing = next[index]; - next[index] = { - ...existing, - ...update, - name: update.name || existing.name, - status: mergeToolStatus(existing.status, update.status), - durationMs: update.durationMs ?? existing.durationMs, - summary: update.summary ?? existing.summary, - updatedAt: update.updatedAt || existing.updatedAt, - }; - } - return next; -} - -function collectToolUpdates(message: unknown, eventState: string): ToolStatus[] { - const updates: ToolStatus[] = []; - const toolResultUpdate = extractToolResultUpdate(message, eventState); - if (toolResultUpdate) updates.push(toolResultUpdate); - updates.push(...extractToolResultBlocks(message, eventState)); - updates.push(...extractToolUseUpdates(message)); - return updates; -} - -/** - * True when an assistant message carries user-visible final output (text or - * image). NOTE: `thinking` blocks are intentionally excluded — they are the - * model's internal monologue and frequently precede tool calls in models like - * MiniMax-M2.7 and gpt-5.5. Treating thinking as "final content" causes the - * history-poll closer in applyLoadedMessages and the runtime final handler to - * misclassify intermediate `[thinking, toolCall]` turns as completed replies, - * which prematurely tears down the `sending` / `activeRunId` / `pendingFinal` - * lifecycle flags and makes the Thinking… indicator vanish mid-tool-chain. - */ -function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { - if (!message) return false; - if (typeof message.content === 'string' && message.content.trim()) return true; - - const content = message.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if (block.type === 'text' && block.text && block.text.trim()) return true; - if (block.type === 'image') return true; - } - } - - const msg = message as unknown as Record; - if (typeof msg.text === 'string' && msg.text.trim()) return true; - - return false; -} - -/** - * True when an assistant message is still waiting on a tool result, i.e. it - * represents an intermediate tool-use turn rather than a finished reply. - * Detected via: - * - explicit stop_reason = "tool_use" / "toolUse" - * - any tool_use / toolCall block in `content` - * - OpenAI-format `tool_calls` array - * Used by applyLoadedMessages and the runtime `final` handler to keep the - * `sending` / `activeRunId` / `pendingFinal` flags armed across tool rounds. - */ -function hasPendingToolUse(message: RawMessage | undefined): boolean { - if (!message) return false; - const reason = getMessageStopReason(message); - if (reason === 'tool_use' || reason === 'tooluse') return true; - - const content = message.content; - if (Array.isArray(content)) { - for (const block of content as ContentBlock[]) { - if (block.type === 'tool_use' || block.type === 'toolCall') return true; - } - } - - const msg = message as unknown as Record; - const toolCalls = msg.tool_calls ?? msg.toolCalls; - if (Array.isArray(toolCalls) && toolCalls.length > 0) return true; - - return false; -} - -function setHistoryPollTimer(timer: ReturnType | null): void { - _historyPollTimer = timer; -} - -function hasErrorRecoveryTimer(): boolean { - return _errorRecoveryTimer != null; -} - -function setLastChatEventAt(value: number): void { - _lastChatEventAt = value; -} - -function getLastChatEventAt(): number { - return _lastChatEventAt; -} - -function setLastAbortedRunId(id: string | null): void { - _lastAbortedRunId = id; -} - -function getLastAbortedRunId(): string | null { - return _lastAbortedRunId; -} - -function queueBlockedRunEvent(runId: string, event: Record): void { - const events = _blockedRunEvents.get(runId) ?? []; - events.push({ ...event }); - if (events.length > 100) events.shift(); - _blockedRunEvents.set(runId, events); -} - -function isRealUserBoundaryMessage(msg: RawMessage): boolean { - if (msg.role !== 'user') return false; - if (!Array.isArray(msg.content)) return true; - const blocks = msg.content as ContentBlock[]; - return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); -} - -function hasAssistantAfterLastRealUser(messages: RawMessage[]): boolean { - for (let i = messages.length - 1; i >= 0; i -= 1) { - if (isRealUserBoundaryMessage(messages[i])) { - return messages.slice(i + 1).some((m) => m.role === 'assistant'); - } - } - return false; -} - -function hasAssistantProgressSinceSend(messages: RawMessage[], lastUserMessageAt: number | null): boolean { - if (!lastUserMessageAt) return false; - const normalized = [...messages]; - while (normalized.length > 0) { - const last = normalized[normalized.length - 1]; - if (last.role === 'user' && !last.timestamp) { - normalized.pop(); - continue; - } - break; - } - return hasAssistantAfterLastRealUser(normalized); -} - -function takeBlockedRunEvents(runId: string): Record[] { - const events = _blockedRunEvents.get(runId) ?? []; - _blockedRunEvents.delete(runId); - return events; -} - -export { - toMs, - clearErrorRecoveryTimer, - clearHistoryPoll, - extractImagesAsAttachedFiles, - getMessageText, - getMessageStopReason, - getMessageErrorMessage, - isTerminalAssistantErrorMessage, - shouldShowRunError, - withoutDismissedRunError, - extractMediaRefs, - extractRawFilePaths, - makeAttachedFile, - enrichWithToolResultFiles, - enrichWithToolCallAttachments, - isInternalMessage, - shouldDropMessageFromHistory, - isToolResultRole, - enrichWithCachedImages, - loadMissingPreviews, - upsertImageCacheEntry, - getCanonicalPrefixFromSessions, - getToolCallFilePath, - collectToolUpdates, - upsertToolStatuses, - hasNonToolAssistantContent, - hasPendingToolUse, - hasAssistantAfterLastRealUser, - hasAssistantProgressSinceSend, - isToolOnlyMessage, - normalizeStreamingMessage, - matchesOptimisticUserMessage, - rememberPendingOptimisticUserMessage, - clearPendingOptimisticUserMessages, - mergePendingOptimisticUserMessages, - snapshotStreamingAssistantMessage, - getLatestOptimisticUserMessage, - hasOptimisticServerEcho, - dropRedundantOptimisticUserMessages, - setHistoryPollTimer, - hasErrorRecoveryTimer, - scheduleRecoverableRuntimeError, - isRecoverableRuntimeError, - setLastChatEventAt, - getLastChatEventAt, - setLastAbortedRunId, - getLastAbortedRunId, - queueBlockedRunEvent, - takeBlockedRunEvents, -}; diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts deleted file mode 100644 index 944748e9..00000000 --- a/src/stores/chat/history-actions.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { hostApi } from '@/lib/host-api'; -import { fetchCronSessionHistory } from '@/lib/cron-session-history'; -import { useGatewayStore } from '@/stores/gateway'; -import { - clearHistoryPoll, - enrichWithToolCallAttachments, - enrichWithCachedImages, - enrichWithToolResultFiles, - getLatestOptimisticUserMessage, - getMessageErrorMessage, - getMessageStopReason, - getMessageText, - shouldDropMessageFromHistory, - shouldShowRunError, - loadMissingPreviews, - mergePendingOptimisticUserMessages, - dropRedundantOptimisticUserMessages, - hasAssistantAfterLastRealUser, - hasOptimisticServerEcho, - isRecoverableRuntimeError, - setLastChatEventAt, - toMs, -} from './helpers'; -import { isCronSessionKey } from './cron-session-utils'; -import { - CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS, - classifyHistoryStartupRetryError, - getStartupHistoryTimeoutOverride, - shouldRetryStartupHistoryLoad, - sleep, -} from './history-startup-retry'; -import { - buildChatHistoryRpcParams, - getChatHistoryMaxChars, -} from './history-rpc-params'; -import { hydrateGatewayHistoryFromTranscript } from './history-transcript-hydrate'; -import type { RawMessage } from './types'; -import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; - -const foregroundHistoryLoadSeen = new Set(); - -async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise { - if (!isCronSessionKey(sessionKey)) return []; - try { - return await fetchCronSessionHistory(sessionKey, limit); - } catch (error) { - console.warn('Failed to load cron fallback history:', error); - return []; - } -} - -export function createHistoryActions( - set: ChatSet, - get: ChatGet, -): Pick { - return { - loadMoreHistory: async () => { - // The legacy split-store path is not active in the Electron app. Keep a - // conservative implementation for type safety; the monolithic store in - // src/stores/chat.ts provides paginated transcript loading. - await get().loadHistory(true); - }, - loadHistory: async (quiet = false) => { - const { currentSessionKey } = get(); - const gatewayState = useGatewayStore.getState?.() as { status?: { pid?: number; connectedAt?: number; port?: number } } | undefined; - const gatewayStatus = gatewayState?.status; - const foregroundLoadKey = `${gatewayStatus?.pid ?? 'none'}:${gatewayStatus?.connectedAt ?? 'none'}:${gatewayStatus?.port ?? 'none'}|${currentSessionKey}`; - const isInitialForegroundLoad = !quiet && !foregroundHistoryLoadSeen.has(foregroundLoadKey); - const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad); - if (!quiet) set({ loading: true, error: null }); - - const isCurrentSession = () => get().currentSessionKey === currentSessionKey; - const getPreviewMergeKey = (message: RawMessage): string => ( - `${message.id ?? ''}|${message.role}|${message.timestamp ?? ''}|${getMessageText(message.content)}` - ); - const mergeHydratedMessages = ( - currentMessages: RawMessage[], - hydratedMessages: RawMessage[], - ): RawMessage[] => { - const hydratedFilesByKey = new Map( - hydratedMessages - .filter((message) => message._attachedFiles?.length) - .map((message) => [ - getPreviewMergeKey(message), - message._attachedFiles!.map((file) => ({ ...file })), - ]), - ); - - return currentMessages.map((message) => { - const attachedFiles = hydratedFilesByKey.get(getPreviewMergeKey(message)); - return attachedFiles - ? { ...message, _attachedFiles: attachedFiles } - : message; - }); - }; - type AttachedFile = NonNullable[number]; - const getAttachmentMergeKey = (file: AttachedFile): string | null => ( - file.filePath || file.gatewayUrl || null - ); - const preserveExistingAttachmentPreviews = ( - currentMessages: RawMessage[], - nextMessages: RawMessage[], - ): RawMessage[] => { - const currentFilesByMessageKey = new Map>(); - for (const message of currentMessages) { - if (!message._attachedFiles?.length) continue; - const filesByKey = new Map(); - for (const file of message._attachedFiles) { - const key = getAttachmentMergeKey(file); - if (!key) continue; - if (!file.preview && !file.fileSize && !file.previewStatus) continue; - filesByKey.set(key, file); - } - if (filesByKey.size > 0) { - currentFilesByMessageKey.set(getPreviewMergeKey(message), filesByKey); - } - } - - if (currentFilesByMessageKey.size === 0) return nextMessages; - - return nextMessages.map((message) => { - if (!message._attachedFiles?.length) return message; - const currentFiles = currentFilesByMessageKey.get(getPreviewMergeKey(message)); - if (!currentFiles) return message; - - let changed = false; - const attachedFiles = message._attachedFiles.map((file) => { - const key = getAttachmentMergeKey(file); - const currentFile = key ? currentFiles.get(key) : undefined; - if (!currentFile) return file; - - let nextFile = file; - if (!nextFile.preview && currentFile.preview) { - nextFile = { ...nextFile, preview: currentFile.preview }; - changed = true; - } - if (!nextFile.fileSize && currentFile.fileSize) { - nextFile = { ...nextFile, fileSize: currentFile.fileSize }; - changed = true; - } - if (!nextFile.previewStatus && currentFile.previewStatus) { - nextFile = { ...nextFile, previewStatus: currentFile.previewStatus }; - changed = true; - } - return nextFile; - }); - - return changed ? { ...message, _attachedFiles: attachedFiles } : message; - }); - }; - - const applyLoadFailure = (errorMessage: string | null) => { - if (!isCurrentSession()) return; - set((state) => { - const hasMessages = state.messages.length > 0; - return { - loading: false, - error: !quiet && errorMessage ? errorMessage : state.error, - ...(hasMessages ? {} : { messages: [] as RawMessage[] }), - }; - }); - }; - - const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => { - if (!isCurrentSession()) return false; - // Before filtering: attach images/files from tool_result messages to the next assistant message - const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); - const messagesWithToolAttachments = enrichWithToolCallAttachments(messagesWithToolImages); - const filteredMessages = messagesWithToolAttachments.filter((msg) => !shouldDropMessageFromHistory(msg)); - // Restore file attachments for user/assistant messages (from cache + text patterns) - const enrichedMessages = enrichWithCachedImages(filteredMessages); - - // Preserve optimistic user messages independently from sending state. - // Gateway phase=end can clear sending before chat.history has persisted - // the user turn; without this, an early quiet reload briefly removes it. - let finalMessages = mergePendingOptimisticUserMessages(currentSessionKey, enrichedMessages); - const userMsgAt = get().lastUserMessageAt; - if (get().sending && userMsgAt) { - const userMsMs = toMs(userMsgAt); - const optimistic = getLatestOptimisticUserMessage(get().messages, userMsMs); - const hasMatchingUser = optimistic - ? hasOptimisticServerEcho(finalMessages, optimistic, userMsMs) - : false; - if (optimistic && !hasMatchingUser) { - finalMessages = [...finalMessages, optimistic]; - } - } - finalMessages = dropRedundantOptimisticUserMessages(currentSessionKey, finalMessages); - finalMessages = preserveExistingAttachmentPreviews(get().messages, finalMessages); - - const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get(); - const userMsTs = lastUserMessageAt ? toMs(lastUserMessageAt) : 0; - const isAfterUserMsg = (msg: RawMessage): boolean => { - if (!userMsTs || !msg.timestamp) return true; - return toMs(msg.timestamp) >= userMsTs; - }; - const isRealUserBoundary = (msg: RawMessage): boolean => { - if (msg.role !== 'user') return false; - if (!Array.isArray(msg.content)) return true; - const blocks = msg.content as Array<{ type?: string }>; - return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); - }; - const postBoundaryMessages = userMsTs - ? filteredMessages.filter((msg) => isAfterUserMsg(msg)) - : (() => { - for (let i = filteredMessages.length - 1; i >= 0; i -= 1) { - if (isRealUserBoundary(filteredMessages[i])) { - return filteredMessages.slice(i + 1); - } - } - return filteredMessages; - })(); - const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant'); - const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary - && getMessageStopReason(lastAssistantAfterBoundary) === 'error' - ? getMessageErrorMessage(lastAssistantAfterBoundary) - : null; - const historyErrorIsTransient = Boolean( - latestTerminalAssistantErrorMessage - && isSendingNow - && isRecoverableRuntimeError(latestTerminalAssistantErrorMessage), - ); - - set({ - messages: finalMessages, - thinkingLevel, - loading: false, - runError: historyErrorIsTransient - ? null - : shouldShowRunError( - currentSessionKey, - latestTerminalAssistantErrorMessage, - get().dismissedRunErrors, - ), - }); - - // Extract first user message text as a session label for display in the toolbar. - // Skip main sessions (key ends with ":main") — they rely on the Gateway-provided - // displayName (e.g. the configured agent name "ClawX") instead. - const isMainSession = currentSessionKey.endsWith(':main'); - if (!isMainSession) { - const firstUserMsg = finalMessages.find((m) => m.role === 'user'); - if (firstUserMsg) { - const labelText = getMessageText(firstUserMsg.content).trim(); - if (labelText) { - const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText; - set((s) => ({ - sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated }, - })); - } - } - } - - // Record last activity time from the last message in history - const lastMsg = finalMessages[finalMessages.length - 1]; - if (lastMsg?.timestamp) { - const lastAt = toMs(lastMsg.timestamp); - set((s) => ({ - sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: lastAt }, - })); - } - - // Async: load missing image previews from disk (updates in background) - loadMissingPreviews(finalMessages).then((updated) => { - if (!isCurrentSession()) return; - if (updated) { - set((state) => ({ - messages: mergeHydratedMessages(state.messages, finalMessages), - })); - } - }); - // If we're sending but haven't received streaming events, check - // whether the loaded history reveals assistant activity (tool calls, - // narration, etc.). Setting pendingFinal surfaces the execution - // graph / activity indicator in the UI. - // - // Note: we intentionally do NOT set sending=false here. Run - // completion is exclusively signalled by the Gateway's phase - // 'completed' event (handled in gateway.ts) or by receiving a - // 'final' streaming event (handled in runtime-event-handlers.ts). - // Attempting to infer completion from message history is fragile - // and leads to premature sending=false during server-side tool - // execution. - if (latestTerminalAssistantErrorMessage && !historyErrorIsTransient) { - clearHistoryPoll(); - set({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - }); - return true; - } - - if (isSendingNow && !pendingFinal && hasAssistantAfterLastRealUser(filteredMessages)) { - setLastChatEventAt(Date.now()); - if (get().error) { - set({ error: null }); - } - set({ pendingFinal: true }); - } - return true; - }; - - try { - const gatewayRpc = async ( - method: string, - params?: unknown, - timeoutMs?: number, - ): Promise => { - return hostApi.gateway.rpc(method, params, timeoutMs); - }; - const chatHistoryParams = buildChatHistoryRpcParams( - currentSessionKey, - 200, - getChatHistoryMaxChars(gatewayRpc), - ); - - let result: { success: boolean; result?: Record; error?: string } | null = null; - let lastError: unknown = null; - - for (let attempt = 0; attempt <= CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length; attempt += 1) { - if (!isCurrentSession()) { - break; - } - - try { - const data = await hostApi.gateway.rpc>( - 'chat.history', - chatHistoryParams, - historyTimeoutOverride, - ); - result = { success: true, result: data }; - lastError = null; - break; - } catch (error) { - lastError = error; - } - - if (!isCurrentSession()) { - break; - } - - const errorKind = classifyHistoryStartupRetryError(lastError); - const shouldRetry = result?.success !== true - && isInitialForegroundLoad - && attempt < CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length - && shouldRetryStartupHistoryLoad(useGatewayStore.getState().status, errorKind); - - if (!shouldRetry) { - break; - } - - console.warn('[chat.history] startup retry scheduled', { - sessionKey: currentSessionKey, - attempt: attempt + 1, - gatewayState: useGatewayStore.getState().status.state, - errorKind, - error: String(lastError), - }); - await sleep(CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS[attempt]!); - } - - if (result?.success && result.result) { - const data = result.result; - let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : []; - const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null; - if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) { - rawMessages = await loadCronFallbackMessages(currentSessionKey, 200); - } else if (rawMessages.length > 0) { - rawMessages = await hydrateGatewayHistoryFromTranscript( - currentSessionKey, - rawMessages, - 200, - get().messages, - ); - } - const applied = applyLoadedMessages(rawMessages, thinkingLevel); - if (applied && isInitialForegroundLoad) { - foregroundHistoryLoadSeen.add(foregroundLoadKey); - } - return; - } - - const errorKind = classifyHistoryStartupRetryError(lastError); - if (isCurrentSession() && isInitialForegroundLoad && errorKind) { - console.warn('[chat.history] startup retry exhausted', { - sessionKey: currentSessionKey, - gatewayState: useGatewayStore.getState().status.state, - errorKind, - error: String(lastError), - }); - } - - const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200); - if (fallbackMessages.length > 0) { - const applied = applyLoadedMessages(fallbackMessages, null); - if (applied && isInitialForegroundLoad) { - foregroundHistoryLoadSeen.add(foregroundLoadKey); - } - } else if (errorKind === 'gateway_startup') { - // Suppress error UI for gateway startup -- the history will load - // once the gateway finishes initializing (via sidebar refresh or - // the next session switch). - set({ loading: false }); - } else { - applyLoadFailure( - result?.error - || (lastError instanceof Error ? lastError.message : String(lastError)) - || 'Failed to load chat history', - ); - } - } catch (err) { - console.warn('Failed to load chat history:', err); - const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200); - if (fallbackMessages.length > 0) { - const applied = applyLoadedMessages(fallbackMessages, null); - if (applied && isInitialForegroundLoad) { - foregroundHistoryLoadSeen.add(foregroundLoadKey); - } - } else { - applyLoadFailure(String(err)); - } - } - }, - }; -} diff --git a/src/stores/chat/history-rpc-params.ts b/src/stores/chat/history-rpc-params.ts deleted file mode 100644 index 62a1456c..00000000 --- a/src/stores/chat/history-rpc-params.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** OpenClaw accepts chat.history maxChars in the range 1–500_000. */ -export const OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP = 500_000; - -export const DEFAULT_CHAT_HISTORY_MAX_CHARS = OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP; - -export type ChatHistoryRpc = ( - method: string, - params?: unknown, - timeoutMs?: number, -) => Promise; - -let cachedMaxChars: number | null = null; -let configRefreshPromise: Promise | null = null; - -function extractChatHistoryMaxChars(snapshot: unknown): number | null { - if (!snapshot || typeof snapshot !== 'object') return null; - const root = snapshot as Record; - const config = (root.config ?? root.parsed ?? root) as Record; - const gateway = config.gateway as Record | undefined; - const webchat = gateway?.webchat as Record | undefined; - const value = webchat?.chatHistoryMaxChars; - if (typeof value !== 'number' || !Number.isFinite(value)) return null; - return Math.min( - OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP, - Math.max(1, Math.floor(value)), - ); -} - -export function resetChatHistoryMaxCharsCache(): void { - cachedMaxChars = null; - configRefreshPromise = null; -} - -function scheduleChatHistoryMaxCharsRefresh(rpc: ChatHistoryRpc): void { - if (configRefreshPromise) return; - configRefreshPromise = rpc('config.get', {}, 5_000) - .then((snapshot) => { - const fromConfig = extractChatHistoryMaxChars(snapshot); - if (fromConfig != null) { - cachedMaxChars = fromConfig; - } - }) - .catch(() => { - // Keep the default cap when config.get is unavailable during startup. - }) - .finally(() => { - configRefreshPromise = null; - }); -} - -export async function resolveChatHistoryMaxChars( - rpc?: ChatHistoryRpc, -): Promise { - return getChatHistoryMaxChars(rpc); -} - -export function getChatHistoryMaxChars(rpc?: ChatHistoryRpc): number { - if (cachedMaxChars != null) return cachedMaxChars; - - cachedMaxChars = DEFAULT_CHAT_HISTORY_MAX_CHARS; - if (rpc) { - scheduleChatHistoryMaxCharsRefresh(rpc); - } - - return cachedMaxChars; -} - -export function buildChatHistoryRpcParams( - sessionKey: string, - limit: number, - maxChars: number, -): { sessionKey: string; limit: number; maxChars: number } { - return { - sessionKey, - limit, - maxChars, - }; -} diff --git a/src/stores/chat/history-startup-retry.ts b/src/stores/chat/history-startup-retry.ts deleted file mode 100644 index 5800f8a7..00000000 --- a/src/stores/chat/history-startup-retry.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { GatewayStatus } from '@/types/gateway'; - -export const CHAT_HISTORY_RPC_TIMEOUT_MS = 35_000; -export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [800, 2_000, 4_000, 8_000] as const; -export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 30_000; -export const CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS = - CHAT_HISTORY_RPC_TIMEOUT_MS + CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS; -export const CHAT_HISTORY_STARTUP_FALLBACK_RACE_MS = 1_500; -export const CHAT_HISTORY_DISK_FALLBACK_TIMEOUT_MS = 4_000; -export const CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS = 15_000; -export const CHAT_HISTORY_LOADING_SAFETY_TIMEOUT_MS = - CHAT_HISTORY_RPC_TIMEOUT_MS * (CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.length + 1) - + CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0) - + 2_000; - -export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable' | 'gateway_startup'; - -export function classifyHistoryStartupRetryError(error: unknown): HistoryRetryErrorKind | null { - const message = String(error).toLowerCase(); - - if ( - message.includes('unavailable during gateway startup') - || message.includes('unavailable during startup') - || message.includes('not yet ready') - || message.includes('service not initialized') - ) { - return 'gateway_startup'; - } - - if ( - message.includes('rpc timeout: chat.history') - || message.includes('rpc timeout: chat:history') - || message.includes('gateway rpc timeout: chat.history') - || message.includes('gateway rpc timeout: chat:history') - || message.includes('gateway ws timeout: chat.history') - || message.includes('gateway ws timeout: chat:history') - || message.includes('request timed out') - ) { - return 'timeout'; - } - - if ( - message.includes('gateway not connected') - || message.includes('gateway socket is not connected') - || message.includes('gateway is unavailable') - || message.includes('service channel unavailable') - || message.includes('websocket closed before handshake') - || message.includes('connect handshake timeout') - || message.includes('gateway ws connect timeout') - || message.includes('gateway connection closed') - ) { - return 'gateway_unavailable'; - } - - return null; -} - -export function shouldRetryStartupHistoryLoad( - gatewayStatus: GatewayStatus | undefined, - errorKind: HistoryRetryErrorKind | null, -): boolean { - if (!gatewayStatus || !errorKind) return false; - - // The gateway explicitly told us it's still initializing -- always retry - if (errorKind === 'gateway_startup') { - return true; - } - - if (gatewayStatus.state === 'starting') { - return true; - } - - if (gatewayStatus.state !== 'running') { - return false; - } - - if (gatewayStatus.connectedAt == null) { - return true; - } - - return Date.now() - gatewayStatus.connectedAt <= CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS; -} - -export async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -export function getStartupHistoryTimeoutOverride( - isInitialForegroundLoad: boolean, -): number | undefined { - return isInitialForegroundLoad ? CHAT_HISTORY_RPC_TIMEOUT_MS : undefined; -} - -export function getHistoryLoadingSafetyTimeout(isInitialForegroundLoad: boolean): number { - return isInitialForegroundLoad - ? CHAT_HISTORY_LOADING_SAFETY_TIMEOUT_MS - : CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS; -} diff --git a/src/stores/chat/history-transcript-fallback.ts b/src/stores/chat/history-transcript-fallback.ts deleted file mode 100644 index 1755dfed..00000000 --- a/src/stores/chat/history-transcript-fallback.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { hostApi } from '@/lib/host-api'; -import type { RawMessage } from './types'; - -export async function loadSessionTranscriptFallback( - sessionKey: string, - limit = 200, -): Promise { - try { - const response = await hostApi.sessions.history({ sessionKey, limit }); - return Array.isArray(response.messages) ? response.messages : []; - } catch (error) { - console.warn('[chat.history] transcript fallback failed:', error); - return []; - } -} diff --git a/src/stores/chat/history-transcript-hydrate.ts b/src/stores/chat/history-transcript-hydrate.ts deleted file mode 100644 index 1f69df35..00000000 --- a/src/stores/chat/history-transcript-hydrate.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { loadSessionTranscriptFallback } from './history-transcript-fallback'; -import { - gatewayHistoryNeedsTranscriptHydration, - mergeGatewayHistoryWithTranscript, -} from './history-transcript-merge'; -import type { RawMessage } from './types'; - -export async function hydrateGatewayHistoryFromTranscript( - sessionKey: string, - gatewayMessages: RawMessage[], - limit: number, - localMessages?: RawMessage[], -): Promise { - if (!gatewayHistoryNeedsTranscriptHydration(gatewayMessages)) { - return gatewayMessages; - } - - const transcriptMessages = await loadSessionTranscriptFallback(sessionKey, limit); - let merged = mergeGatewayHistoryWithTranscript(gatewayMessages, transcriptMessages); - - if (gatewayHistoryNeedsTranscriptHydration(merged) && localMessages?.length) { - merged = mergeGatewayHistoryWithTranscript(merged, localMessages); - } - - return merged; -} diff --git a/src/stores/chat/history-transcript-merge.ts b/src/stores/chat/history-transcript-merge.ts deleted file mode 100644 index a5ede633..00000000 --- a/src/stores/chat/history-transcript-merge.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { getMessageText } from './helpers'; -import type { RawMessage } from './types'; - -const TRUNCATION_SUFFIXES = [ - /\n?\.\.\.\(truncated\)\.\.\.$/, - /\n?…\(truncated\)…$/, - /\n?\[chat\.history omitted: message too large\]$/, -]; - -export function isTruncatedHistoryText(text: string): boolean { - if (!text) return false; - return TRUNCATION_SUFFIXES.some((pattern) => pattern.test(text)); -} - -function stripTruncationSuffix(text: string): string { - let result = text; - for (const pattern of TRUNCATION_SUFFIXES) { - result = result.replace(pattern, ''); - } - return result; -} - -function replaceTruncatedContent( - gatewayContent: unknown, - transcriptContent: unknown, -): unknown { - if (typeof gatewayContent === 'string' && typeof transcriptContent === 'string') { - if (!isTruncatedHistoryText(gatewayContent)) return gatewayContent; - if (isTruncatedHistoryText(transcriptContent)) return gatewayContent; - const gatewayPrefix = stripTruncationSuffix(gatewayContent); - if ( - transcriptContent.length > gatewayPrefix.length - && (transcriptContent.startsWith(gatewayPrefix) || gatewayPrefix.length >= 64) - ) { - return transcriptContent; - } - return gatewayContent; - } - - if (!Array.isArray(gatewayContent) || !Array.isArray(transcriptContent)) { - return gatewayContent; - } - - const gatewayBlocks = gatewayContent as Array<{ type?: string; text?: string }>; - const transcriptBlocks = transcriptContent as Array<{ type?: string; text?: string }>; - if (gatewayBlocks.length !== transcriptBlocks.length) { - const gatewayText = getMessageText(gatewayContent); - const transcriptText = getMessageText(transcriptContent); - if (isTruncatedHistoryText(gatewayText) && !isTruncatedHistoryText(transcriptText)) { - const gatewayPrefix = stripTruncationSuffix(gatewayText); - if ( - transcriptText.length > gatewayPrefix.length - && (transcriptText.startsWith(gatewayPrefix) || gatewayPrefix.length >= 64) - ) { - return transcriptContent; - } - } - return gatewayContent; - } - - let changed = false; - const mergedBlocks = gatewayBlocks.map((block, index) => { - if (block.type !== 'text' || typeof block.text !== 'string') return block; - const transcriptBlock = transcriptBlocks[index]; - if (transcriptBlock?.type !== 'text' || typeof transcriptBlock.text !== 'string') { - return block; - } - const nextText = replaceTruncatedContent(block.text, transcriptBlock.text); - if (nextText !== block.text) { - changed = true; - return { ...block, text: nextText as string }; - } - return block; - }); - - return changed ? mergedBlocks : gatewayContent; -} - -function messageMatchKey(message: RawMessage): string { - if (message.id) return `id:${message.id}`; - return `rt:${message.role}|${message.timestamp ?? ''}`; -} - -function buildTranscriptLookup(transcriptMessages: RawMessage[]): Map { - const lookup = new Map(); - for (const message of transcriptMessages) { - lookup.set(messageMatchKey(message), message); - } - return lookup; -} - -export function gatewayHistoryNeedsTranscriptHydration(messages: RawMessage[]): boolean { - return messages.some((message) => isTruncatedHistoryText(getMessageText(message.content))); -} - -export function mergeGatewayHistoryWithTranscript( - gatewayMessages: RawMessage[], - transcriptMessages: RawMessage[], -): RawMessage[] { - if (gatewayMessages.length === 0 || transcriptMessages.length === 0) { - return gatewayMessages; - } - - const lookup = buildTranscriptLookup(transcriptMessages); - return gatewayMessages.map((message, index) => { - const transcriptMatch = lookup.get(messageMatchKey(message)) - ?? transcriptMessages[index]; - if (!transcriptMatch) return message; - - const nextContent = replaceTruncatedContent(message.content, transcriptMatch.content); - if (nextContent === message.content) return message; - return { ...message, content: nextContent }; - }); -} diff --git a/src/stores/chat/internal.ts b/src/stores/chat/internal.ts deleted file mode 100644 index 5430a4d3..00000000 --- a/src/stores/chat/internal.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { DEFAULT_SESSION_KEY, type ChatState } from './types'; -import { createRuntimeActions } from './runtime-actions'; -import { createSessionHistoryActions } from './session-history-actions'; -import type { ChatGet, ChatSet } from './store-api'; - -export const initialChatState: Pick< - ChatState, - | 'messages' - | 'loading' - | 'error' - | 'runError' - | 'dismissedRunErrors' - | 'sending' - | 'activeRunId' - | 'streamingText' - | 'streamingMessage' - | 'streamingTools' - | 'pendingFinal' - | 'lastUserMessageAt' - | 'pendingToolImages' - | 'sessions' - | 'currentSessionKey' - | 'currentAgentId' - | 'sessionLabels' - | 'sessionLastActivity' - | 'thinkingLevel' -> = { - messages: [], - loading: false, - error: null, - runError: null, - dismissedRunErrors: {}, - - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - - sessions: [], - currentSessionKey: DEFAULT_SESSION_KEY, - currentAgentId: 'main', - sessionLabels: {}, - sessionLastActivity: {}, - - thinkingLevel: null, -}; - -export function createChatActions( - set: ChatSet, - get: ChatGet, -): Pick< - ChatState, - | 'loadSessions' - | 'switchSession' - | 'selectAcpSession' - | 'newSession' - | 'acknowledgeAcpSessionCreated' - | 'deleteSession' - | 'renameSession' - | 'cleanupEmptySession' - | 'loadHistory' - | 'sendMessage' - | 'abortRun' - | 'handleChatEvent' - | 'refresh' - | 'clearError' -> { - return { - ...createSessionHistoryActions(set, get), - ...createRuntimeActions(set, get), - }; -} diff --git a/src/stores/chat/runtime-actions.ts b/src/stores/chat/runtime-actions.ts deleted file mode 100644 index 1d52026f..00000000 --- a/src/stores/chat/runtime-actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; -import { createRuntimeEventActions } from './runtime-event-actions'; -import { createRuntimeSendActions } from './runtime-send-actions'; -import { createRuntimeUiActions } from './runtime-ui-actions'; - -export function createRuntimeActions(set: ChatSet, get: ChatGet): RuntimeActions { - return { - ...createRuntimeSendActions(set, get), - ...createRuntimeEventActions(set, get), - ...createRuntimeUiActions(set, get), - }; -} diff --git a/src/stores/chat/runtime-event-actions.ts b/src/stores/chat/runtime-event-actions.ts deleted file mode 100644 index 4c9c7ad0..00000000 --- a/src/stores/chat/runtime-event-actions.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - clearHistoryPoll, - getLastAbortedRunId, - getMessageStopReason, - queueBlockedRunEvent, - setLastAbortedRunId, - setLastChatEventAt, -} from './helpers'; -import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; -import { handleRuntimeEventState } from './runtime-event-handlers'; - -export function createRuntimeEventActions(set: ChatSet, get: ChatGet): Pick { - return { - handleChatEvent: (event: Record) => { - const runId = String(event.runId || ''); - const eventState = String(event.state || ''); - const eventSessionKey = event.sessionKey != null ? String(event.sessionKey) : null; - const { activeRunId, currentSessionKey } = get(); - - // Only process events for the current session (when sessionKey is present) - if (eventSessionKey != null && eventSessionKey !== currentSessionKey) return; - - // Only process events for the active run (or if no active run set) - if (activeRunId && runId && runId !== activeRunId) return; - - // Reject lingering events from a run that the user explicitly aborted. - // The 'aborted' confirmation event is allowed through to finalize state. - // '*' is a wildcard meaning "abort was requested before we knew the runId". - const lastAbortedRunId = getLastAbortedRunId(); - if (lastAbortedRunId && runId && (lastAbortedRunId === '*' || runId === lastAbortedRunId)) { - if (eventState === 'aborted' && lastAbortedRunId === '*') { - // Gateway confirmed which run was aborted. Narrow the wildcard so - // later unrelated runs can be adopted while this run stays blocked. - setLastAbortedRunId(runId); - } - // Let the 'aborted' event fall through to handleRuntimeEventState - // which properly clears all state. Other wildcard-blocked events may - // belong to a newer send whose runId has not returned yet, so keep a - // bounded queue and replay only if that runId becomes the active run. - if (eventState !== 'aborted') { - if (lastAbortedRunId === '*' && !activeRunId && get().sending) { - queueBlockedRunEvent(runId, event); - } - return; - } - } - - setLastChatEventAt(Date.now()); - - // Defensive: if state is missing but we have a message, try to infer state. - let resolvedState = eventState; - if (!resolvedState && event.message && typeof event.message === 'object') { - const msg = event.message as Record; - const stopReason = getMessageStopReason(msg); - if (stopReason) { - resolvedState = stopReason === 'error' ? 'error' : 'final'; - } else if (msg.role || msg.content) { - resolvedState = 'delta'; - } - } - - // Only pause the history poll when we receive actual streaming data. - // The gateway sends "agent" events with { phase, startedAt } that carry - // no message — these must NOT kill the poll, since the poll is our only - // way to track progress when the gateway doesn't stream intermediate turns. - const hasUsefulData = resolvedState === 'delta' || resolvedState === 'final' - || resolvedState === 'error' || resolvedState === 'aborted'; - if (hasUsefulData) { - clearHistoryPoll(); - // Adopt run started from another client (e.g. console at 127.0.0.1:18789): - // show loading/streaming in the app when this session has an active run. - const { sending } = get(); - if (!sending && runId) { - set({ sending: true, activeRunId: runId, error: null, runError: null }); - } - } - - handleRuntimeEventState(set, get, event, resolvedState, runId); - }, - }; -} diff --git a/src/stores/chat/runtime-event-handlers.ts b/src/stores/chat/runtime-event-handlers.ts deleted file mode 100644 index 69d3010d..00000000 --- a/src/stores/chat/runtime-event-handlers.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { - clearErrorRecoveryTimer, - clearHistoryPoll, - collectToolUpdates, - extractImagesAsAttachedFiles, - extractMediaRefs, - getMessageErrorMessage, - extractRawFilePaths, - getMessageText, - getToolCallFilePath, - hasNonToolAssistantContent, - hasPendingToolUse, - isInternalMessage, - isRecoverableRuntimeError, - isTerminalAssistantErrorMessage, - isToolOnlyMessage, - isToolResultRole, - makeAttachedFile, - normalizeStreamingMessage, - scheduleRecoverableRuntimeError, - snapshotStreamingAssistantMessage, - upsertToolStatuses, -} from './helpers'; -import type { AttachedFileMeta, RawMessage } from './types'; -import type { ChatGet, ChatSet } from './store-api'; - -export function handleRuntimeEventState( - set: ChatSet, - get: ChatGet, - event: Record, - resolvedState: string, - runId: string, -): void { - switch (resolvedState) { - case 'started': { - // Run just started (e.g. from console); show loading immediately. - const { sending: currentSending } = get(); - if (!currentSending && runId) { - set({ sending: true, activeRunId: runId, error: null }); - } - break; - } - case 'delta': { - // If we're receiving new deltas, the Gateway has recovered from any - // prior error — cancel the error finalization timer and clear the - // stale error banner so the user sees the live stream again. - clearErrorRecoveryTimer(); - if (get().error || get().runError) set({ error: null, runError: null }); - const updates = collectToolUpdates(event.message, resolvedState); - set((s) => ({ - streamingMessage: (() => { - if (event.message && typeof event.message === 'object') { - const msgRole = (event.message as RawMessage).role; - if (isToolResultRole(msgRole)) return s.streamingMessage; - // During multi-model fallback the Gateway may emit a delta with an - // empty or role-only message (e.g. `{}` or `{ role: 'assistant' }`) - // to signal a model switch. Accepting such a value would silently - // discard all content accumulated so far in streamingMessage. - // Only replace when the incoming message carries actual payload. - const msgObj = event.message as RawMessage; - // During multi-model fallback the Gateway may emit an empty or - // role-only delta (e.g. `{}` or `{ role: 'assistant' }`) to signal - // a model switch. If we already have accumulated streaming content, - // accepting such a message would silently discard it. Only guard - // when there IS existing content to protect; when streamingMessage - // is still null, let any delta through so the UI can start showing - // the typing indicator immediately. - if (s.streamingMessage && msgObj.content === undefined) { - return s.streamingMessage; - } - } - return normalizeStreamingMessage(event.message ?? s.streamingMessage); - })(), - streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, - })); - break; - } - case 'final': { - clearErrorRecoveryTimer(); - if (get().error || get().runError) set({ error: null, runError: null }); - // Message complete - add to history and clear streaming - const finalMsg = event.message as RawMessage | undefined; - if (finalMsg) { - const normalizedFinalMessage = normalizeStreamingMessage(finalMsg) as RawMessage; - if (isTerminalAssistantErrorMessage(normalizedFinalMessage)) { - handleRuntimeEventState( - set, - get, - { - ...event, - errorMessage: getMessageErrorMessage(normalizedFinalMessage) ?? event.errorMessage, - message: normalizedFinalMessage, - }, - 'error', - runId, - ); - break; - } - const updates = collectToolUpdates(normalizedFinalMessage, resolvedState); - // Filter out internal-only final responses (NO_REPLY, HEARTBEAT_OK, etc.) - // before adding to messages. Without this guard, the internal token appears - // briefly in the UI until loadHistory replaces the message list — and if the - // quiet-mode reload is debounced away, the token can stay visible permanently. - if (isInternalMessage(normalizedFinalMessage)) { - set({ - streamingText: '', - streamingMessage: null, - sending: false, - activeRunId: null, - pendingFinal: false, - streamingTools: [], - pendingToolImages: [], - }); - clearHistoryPoll(); - void get().loadHistory(true); - break; - } - if (isToolResultRole(normalizedFinalMessage.role)) { - // Resolve file path from the streaming assistant message's matching tool call - const currentStreamForPath = get().streamingMessage as RawMessage | null; - const matchedPath = (currentStreamForPath && normalizedFinalMessage.toolCallId) - ? getToolCallFilePath(currentStreamForPath, normalizedFinalMessage.toolCallId) - : undefined; - - // Mirror `enrichWithToolResultFiles`: collect non-image - // artifacts for the next assistant message. Image content - // blocks (vision data) and image-typed raw paths in the - // tool's stdout are intermediate process noise — see the - // comment in `helpers.ts::enrichWithToolResultFiles`. - const toolFiles: AttachedFileMeta[] = extractImagesAsAttachedFiles(normalizedFinalMessage.content) - .filter((file) => !file.mimeType.startsWith('image/')) - .map((file) => (file.source ? file : { ...file, source: 'tool-result' })); - if (matchedPath) { - for (const f of toolFiles) { - if (!f.filePath) { - f.filePath = matchedPath; - f.fileName = matchedPath.split(/[\\/]/).pop() || 'image'; - } - } - } - const text = getMessageText(normalizedFinalMessage.content); - if (text) { - const mediaRefs = extractMediaRefs(text); - const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); - for (const ref of mediaRefs) toolFiles.push(makeAttachedFile(ref, 'tool-result')); - for (const ref of extractRawFilePaths(text)) { - if (mediaRefPaths.has(ref.filePath)) continue; - if (ref.mimeType.startsWith('image/')) continue; - toolFiles.push(makeAttachedFile(ref, 'tool-result')); - } - } - set((s) => { - // Snapshot the current streaming assistant message (thinking + tool_use) into - // messages[] before clearing it. The Gateway does NOT send separate 'final' - // events for intermediate tool-use turns — it only sends deltas and then the - // tool result. Without snapshotting here, the intermediate thinking+tool steps - // would be overwritten by the next turn's deltas and never appear in the UI. - const currentStream = s.streamingMessage as RawMessage | null; - const snapshotMsgs = snapshotStreamingAssistantMessage(currentStream, s.messages, runId); - return { - messages: snapshotMsgs.length > 0 ? [...s.messages, ...snapshotMsgs] : s.messages, - streamingText: '', - streamingMessage: null, - pendingFinal: true, - pendingToolImages: toolFiles.length > 0 - ? [...s.pendingToolImages, ...toolFiles] - : s.pendingToolImages, - streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, - }; - }); - break; - } - // Mixed `[thinking, text, toolCall]` messages with stop_reason="tool_use" - // (some MiniMax / gpt-5.5 variants emit these) are still intermediate - // turns even though they carry user-visible text. Treat them as - // tool-only for lifecycle purposes so the run stays "open" until the - // truly final reply (without a pending tool call) arrives. - const pendingTool = hasPendingToolUse(normalizedFinalMessage); - const toolOnly = isToolOnlyMessage(normalizedFinalMessage) || pendingTool; - const hasOutput = !pendingTool && hasNonToolAssistantContent(normalizedFinalMessage); - const msgId = normalizedFinalMessage.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`); - set((s) => { - const nextTools = updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools; - const streamingTools = hasOutput ? [] : nextTools; - - // Attach any files collected from preceding tool results, - // plus file paths mentioned directly in the final assistant - // text (e.g. `/Users/.../1000行示例.xlsx`). The latter is - // what turns a plain path in the bubble into a clickable card - // immediately, before the quiet history reload finishes. - const pendingImgs = s.pendingToolImages; - const ownText = getMessageText(normalizedFinalMessage.content); - const ownMediaRefs = ownText ? extractMediaRefs(ownText) : []; - const ownMediaPaths = new Set(ownMediaRefs.map(r => r.filePath)); - const ownFiles = ownText - ? [ - ...ownMediaRefs.map(ref => makeAttachedFile(ref, 'message-ref')), - ...extractRawFilePaths(ownText) - .filter(ref => !ownMediaPaths.has(ref.filePath)) - .map(ref => makeAttachedFile(ref, 'message-ref')), - ] - : []; - const attachedFiles = [...pendingImgs]; - const attachedPaths = new Set(attachedFiles.map(file => file.filePath).filter(Boolean)); - for (const file of ownFiles) { - if (file.filePath && attachedPaths.has(file.filePath)) continue; - if (file.filePath) attachedPaths.add(file.filePath); - attachedFiles.push(file); - } - const msgWithImages: RawMessage = pendingImgs.length > 0 - ? { - ...normalizedFinalMessage, - role: (normalizedFinalMessage.role || 'assistant') as RawMessage['role'], - id: msgId, - _attachedFiles: [...(normalizedFinalMessage._attachedFiles || []), ...attachedFiles], - } - : attachedFiles.length > 0 - ? { - ...normalizedFinalMessage, - role: (normalizedFinalMessage.role || 'assistant') as RawMessage['role'], - id: msgId, - _attachedFiles: [...(normalizedFinalMessage._attachedFiles || []), ...attachedFiles], - } - : { ...normalizedFinalMessage, role: (normalizedFinalMessage.role || 'assistant') as RawMessage['role'], id: msgId }; - const clearPendingImages = { pendingToolImages: [] as AttachedFileMeta[] }; - - // Check if message already exists (prevent duplicates) - const alreadyExists = s.messages.some(m => m.id === msgId); - if (alreadyExists) { - return toolOnly ? { - streamingText: '', - streamingMessage: null, - pendingFinal: true, - streamingTools, - ...clearPendingImages, - } : { - streamingText: '', - streamingMessage: null, - sending: hasOutput ? false : s.sending, - activeRunId: hasOutput ? null : s.activeRunId, - pendingFinal: hasOutput ? false : true, - streamingTools, - ...clearPendingImages, - }; - } - return toolOnly ? { - messages: [...s.messages, msgWithImages], - streamingText: '', - streamingMessage: null, - pendingFinal: true, - streamingTools, - ...clearPendingImages, - } : { - messages: [...s.messages, msgWithImages], - streamingText: '', - streamingMessage: null, - sending: hasOutput ? false : s.sending, - activeRunId: hasOutput ? null : s.activeRunId, - pendingFinal: hasOutput ? false : true, - streamingTools, - ...clearPendingImages, - }; - }); - // After the final response, quietly reload history to surface all intermediate - // tool-use turns (thinking + tool blocks) from the Gateway's authoritative record. - if (hasOutput && !toolOnly) { - clearHistoryPoll(); - void get().loadHistory(true); - } - } else { - // No message in final event - reload history to get complete data - set({ streamingText: '', streamingMessage: null, pendingFinal: true }); - get().loadHistory(); - } - break; - } - case 'error': { - const errorMsg = String( - event.errorMessage - || getMessageErrorMessage(event.message) - || 'An error occurred', - ); - const terminalAssistantError = isTerminalAssistantErrorMessage(event.message); - const wasSending = get().sending; - const sessionKeyAtError = get().currentSessionKey; - const recoverable = wasSending && isRecoverableRuntimeError(errorMsg); - - const commitRuntimeError = () => { - const currentStream = get().streamingMessage as RawMessage | null; - const errorSnapshot = snapshotStreamingAssistantMessage( - currentStream, - get().messages, - `error-${runId || Date.now()}`, - ); - if (errorSnapshot.length > 0) { - set((s) => ({ - messages: [...s.messages, ...errorSnapshot], - })); - } - - set({ - error: terminalAssistantError ? null : errorMsg, - runError: terminalAssistantError ? errorMsg : null, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - }); - clearHistoryPoll(); - clearErrorRecoveryTimer(); - if (wasSending) { - void get().loadHistory(true); - } - }; - - if (recoverable) { - scheduleRecoverableRuntimeError(() => { - if (get().currentSessionKey !== sessionKeyAtError) return; - if (runId && get().activeRunId && get().activeRunId !== runId) return; - if (!get().sending && !get().error && !get().runError) return; - commitRuntimeError(); - }); - break; - } - - commitRuntimeError(); - break; - } - case 'aborted': { - clearHistoryPoll(); - clearErrorRecoveryTimer(); - set({ - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - }); - break; - } - default: { - // Unknown or empty state — if we're currently sending and receive an event - // with a message, attempt to process it as streaming data. This handles - // edge cases where the Gateway sends events without a state field. - const { sending } = get(); - if (sending && event.message && typeof event.message === 'object') { - console.warn(`[handleChatEvent] Unknown event state "${resolvedState}", treating message as streaming delta. Event keys:`, Object.keys(event)); - const updates = collectToolUpdates(event.message, 'delta'); - set((s) => ({ - streamingMessage: normalizeStreamingMessage(event.message ?? s.streamingMessage), - streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, - })); - } - break; - } - } -} diff --git a/src/stores/chat/runtime-graph.ts b/src/stores/chat/runtime-graph.ts deleted file mode 100644 index 422b92ea..00000000 --- a/src/stores/chat/runtime-graph.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { ChatRuntimeEvent } from '../../../shared/chat-runtime-events'; -import { - extractImagesAsAttachedFiles, - extractMediaRefs, - extractRawFilePaths, - getMessageText, - makeAttachedFile, -} from './helpers'; -import type { AttachedFileMeta, ChatRuntimeRunState } from './types'; - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' ? value as Record : null; -} - -function cloneRunState(runId: string, event: ChatRuntimeEvent): ChatRuntimeRunState { - return { - runId, - sessionKey: event.sessionKey, - status: event.type === 'run.ended' ? event.status : 'running', - startedAt: event.type === 'run.started' ? event.startedAt : undefined, - endedAt: event.type === 'run.ended' ? event.endedAt : undefined, - assistantText: '', - thinkingText: '', - events: [], - }; -} - -function stableRuntimeFingerprint(value: unknown): string { - if (value === undefined) return 'undefined'; - if (value === null || typeof value !== 'object') return `${typeof value}:${String(value)}`; - if (Array.isArray(value)) return `[${value.map(stableRuntimeFingerprint).join(',')}]`; - return `{${Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, child]) => `${JSON.stringify(key)}:${stableRuntimeFingerprint(child)}`) - .join(',')}}`; -} - -function sameRuntimeEvent(left: ChatRuntimeEvent | undefined, right: ChatRuntimeEvent): boolean { - if (!left) return false; - if (left.runId !== right.runId || left.type !== right.type) return false; - if (typeof left.seq === 'number' && typeof right.seq === 'number') { - return left.seq === right.seq; - } - if (left.type === 'tool.started') { - return right.type === left.type && right.toolCallId === left.toolCallId; - } - if (left.type === 'tool.updated') { - return right.type === left.type - && right.toolCallId === left.toolCallId - && stableRuntimeFingerprint(right.partialResult) === stableRuntimeFingerprint(left.partialResult); - } - if (left.type === 'tool.completed') { - return right.type === left.type - && right.toolCallId === left.toolCallId - && right.isError === left.isError - && stableRuntimeFingerprint(right.result) === stableRuntimeFingerprint(left.result) - && stableRuntimeFingerprint(right.meta) === stableRuntimeFingerprint(left.meta); - } - if (left.type === 'command.output') { - return right.type === left.type - && right.toolCallId === left.toolCallId - && right.itemId === left.itemId - && right.phase === left.phase - && right.output === left.output; - } - if (left.type === 'patch.completed') { - return right.type === left.type && right.toolCallId === left.toolCallId && right.summary === left.summary; - } - if (left.type === 'approval.updated') { - return right.type === left.type - && right.toolCallId === left.toolCallId - && right.status === left.status - && right.phase === left.phase - && right.message === left.message; - } - if (left.type === 'assistant.delta') { - return right.type === left.type && right.text === left.text && right.delta === left.delta; - } - if (left.type === 'thinking.delta') { - return right.type === left.type && right.text === left.text && right.delta === left.delta; - } - if (left.type === 'run.started') return right.type === left.type; - if (left.type === 'run.ended') return right.type === left.type && right.status === left.status && right.endedAt === left.endedAt; - return false; -} - -export function applyRuntimeEventToRuns( - currentRuns: Record, - event: ChatRuntimeEvent, -): Record { - const existing = currentRuns[event.runId] ?? cloneRunState(event.runId, event); - const nextRun: ChatRuntimeRunState = { - ...existing, - sessionKey: event.sessionKey ?? existing.sessionKey, - events: sameRuntimeEvent(existing.events.at(-1), event) - ? existing.events - : [...existing.events, event], - }; - - switch (event.type) { - case 'run.started': - nextRun.status = 'running'; - nextRun.startedAt = event.startedAt ?? nextRun.startedAt; - nextRun.endedAt = undefined; - break; - case 'run.ended': - nextRun.status = event.status; - nextRun.endedAt = event.endedAt ?? event.ts ?? Date.now(); - break; - case 'assistant.delta': { - const incoming = event.text ?? event.delta ?? ''; - if (incoming) { - if (event.replace) { - nextRun.assistantText = incoming; - } else if (event.text) { - nextRun.assistantText = event.text.startsWith(nextRun.assistantText) - ? event.text - : event.text; - } else { - nextRun.assistantText = `${nextRun.assistantText}${event.delta ?? ''}`; - } - } - break; - } - case 'thinking.delta': { - const incoming = event.text ?? event.delta ?? ''; - if (incoming) { - if (event.text) { - nextRun.thinkingText = event.text.startsWith(nextRun.thinkingText) - ? event.text - : event.text; - } else { - nextRun.thinkingText = `${nextRun.thinkingText}${event.delta ?? ''}`; - } - } - break; - } - default: - break; - } - - return { - ...currentRuns, - [event.runId]: nextRun, - }; -} - -function collectRuntimeResultTexts(result: unknown): string[] { - const texts: string[] = []; - if (typeof result === 'string' && result.trim()) { - texts.push(result); - } - if (Array.isArray(result)) { - const text = getMessageText(result); - if (text.trim()) texts.push(text); - } - const record = asRecord(result); - if (!record) return texts; - - const candidates = [record.content, record.output, record.summary, record.error, record.stdout, record.stderr]; - for (const candidate of candidates) { - if (typeof candidate === 'string' && candidate.trim()) { - texts.push(candidate); - continue; - } - const text = getMessageText(candidate); - if (text.trim()) texts.push(text); - } - - return texts; -} - -export function extractToolCompletedFiles(event: ChatRuntimeEvent): AttachedFileMeta[] { - if (event.type !== 'tool.completed') return []; - - const files: AttachedFileMeta[] = extractImagesAsAttachedFiles(event.result) - .filter((file) => !file.mimeType.startsWith('image/')) - .map((file) => (file.source ? file : { ...file, source: 'tool-result' as const })); - - const seenPaths = new Set(files.map((file) => file.filePath).filter(Boolean)); - for (const text of collectRuntimeResultTexts(event.result)) { - const mediaRefs = extractMediaRefs(text); - const mediaRefPaths = new Set(mediaRefs.map((ref) => ref.filePath)); - for (const ref of mediaRefs) { - if (seenPaths.has(ref.filePath)) continue; - const file = makeAttachedFile(ref, 'tool-result'); - seenPaths.add(ref.filePath); - files.push(file); - } - for (const ref of extractRawFilePaths(text)) { - if (ref.mimeType.startsWith('image/')) continue; - if (mediaRefPaths.has(ref.filePath) || seenPaths.has(ref.filePath)) continue; - const file = makeAttachedFile(ref, 'tool-result'); - seenPaths.add(ref.filePath); - files.push(file); - } - } - - return files; -} diff --git a/src/stores/chat/runtime-send-actions.ts b/src/stores/chat/runtime-send-actions.ts deleted file mode 100644 index 5701545c..00000000 --- a/src/stores/chat/runtime-send-actions.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { hostApi, type ChatSendWithMediaResult } from '@/lib/host-api'; -import { useAgentsStore } from '@/stores/agents'; -import { - clearErrorRecoveryTimer, - clearHistoryPoll, - getLastAbortedRunId, - getLastChatEventAt, - hasAssistantProgressSinceSend, - setHistoryPollTimer, - setLastChatEventAt, - setLastAbortedRunId, - rememberPendingOptimisticUserMessage, - takeBlockedRunEvents, - upsertImageCacheEntry, - withoutDismissedRunError, -} from './helpers'; -import type { ChatSession, RawMessage } from './types'; -import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; - -function normalizeAgentId(value: string | undefined | null): string { - return (value ?? '').trim().toLowerCase() || 'main'; -} - -function getAgentIdFromSessionKey(sessionKey: string): string { - if (!sessionKey.startsWith('agent:')) return 'main'; - const [, agentId] = sessionKey.split(':'); - return agentId || 'main'; -} - -function buildFallbackMainSessionKey(agentId: string): string { - return `agent:${normalizeAgentId(agentId)}:main`; -} - -function resolveMainSessionKeyForAgent(agentId: string | undefined | null): string | null { - if (!agentId) return null; - const normalizedAgentId = normalizeAgentId(agentId); - const summary = useAgentsStore.getState().agents.find((agent) => agent.id === normalizedAgentId); - return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId); -} - -function ensureSessionEntry(sessions: ChatSession[], sessionKey: string): ChatSession[] { - if (sessions.some((session) => session.key === sessionKey)) { - return sessions; - } - return [...sessions, { key: sessionKey, displayName: sessionKey }]; -} - -let sendGeneration = 0; - -export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick { - return { - sendMessage: async ( - text: string, - attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>, - targetAgentId?: string | null, - ) => { - const trimmed = text.trim(); - if (!trimmed && (!attachments || attachments.length === 0)) return; - const currentSendGeneration = ++sendGeneration; - - const targetSessionKey = resolveMainSessionKeyForAgent(targetAgentId) ?? get().currentSessionKey; - - if (get().sending && targetSessionKey === get().currentSessionKey) { - return; - } - - if (targetSessionKey !== get().currentSessionKey) { - const current = get(); - const leavingEmpty = !current.currentSessionKey.endsWith(':main') && current.messages.length === 0; - set((s) => ({ - currentSessionKey: targetSessionKey, - currentAgentId: getAgentIdFromSessionKey(targetSessionKey), - sessions: ensureSessionEntry( - leavingEmpty ? s.sessions.filter((session) => session.key !== current.currentSessionKey) : s.sessions, - targetSessionKey, - ), - sessionLabels: leavingEmpty - ? Object.fromEntries(Object.entries(s.sessionLabels).filter(([key]) => key !== current.currentSessionKey)) - : s.sessionLabels, - sessionLastActivity: leavingEmpty - ? Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([key]) => key !== current.currentSessionKey)) - : s.sessionLastActivity, - messages: [], - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - })); - await get().loadHistory(true); - } - - const currentSessionKey = targetSessionKey; - - // Add user message optimistically (with local file metadata for UI display) - const nowMs = Date.now(); - const userMsg: RawMessage = { - role: 'user', - content: trimmed || (attachments?.length ? '(file attached)' : ''), - timestamp: nowMs / 1000, - id: crypto.randomUUID(), - _attachedFiles: attachments?.map(a => ({ - fileName: a.fileName, - mimeType: a.mimeType, - fileSize: a.fileSize, - preview: a.preview, - filePath: a.stagedPath, - source: 'user-upload', - })), - }; - rememberPendingOptimisticUserMessage(currentSessionKey, userMsg, nowMs); - set((s) => ({ - messages: [...s.messages, userMsg], - sending: true, - error: null, - runError: null, - dismissedRunErrors: withoutDismissedRunError(s.dismissedRunErrors, currentSessionKey), - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: nowMs, - })); - - // Update session label with first user message text as soon as it's sent - const { sessionLabels, messages } = get(); - const isFirstMessage = !messages.slice(0, -1).some((m) => m.role === 'user'); - if (!currentSessionKey.endsWith(':main') && isFirstMessage && !sessionLabels[currentSessionKey] && trimmed) { - const truncated = trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed; - set((s) => ({ sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated } })); - } - - // Mark this session as most recently active - set((s) => ({ sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: nowMs } })); - - // Start the history poll and safety timeout IMMEDIATELY (before the - // RPC await) because the gateway's chat.send RPC may block until the - // entire agentic conversation finishes — the poll must run in parallel. - setLastChatEventAt(Date.now()); - clearHistoryPoll(); - clearErrorRecoveryTimer(); - - const POLL_START_DELAY = 3_000; - const POLL_INTERVAL = 4_000; - const pollHistory = () => { - const state = get(); - if (!state.sending) { clearHistoryPoll(); return; } - if (state.streamingMessage) { - setHistoryPollTimer(setTimeout(pollHistory, POLL_INTERVAL)); - return; - } - state.loadHistory(true); - setHistoryPollTimer(setTimeout(pollHistory, POLL_INTERVAL)); - }; - setHistoryPollTimer(setTimeout(pollHistory, POLL_START_DELAY)); - - const SAFETY_TIMEOUT_MS = 90_000; - const checkStuck = () => { - const state = get(); - if (!state.sending) return; - if (state.streamingMessage || state.streamingText) return; - if (state.pendingFinal) { - setTimeout(checkStuck, 10_000); - return; - } - if (hasAssistantProgressSinceSend(state.messages, state.lastUserMessageAt)) { - setLastChatEventAt(Date.now()); - if (state.error) { - set({ error: null }); - } - setTimeout(checkStuck, 10_000); - return; - } - if (Date.now() - getLastChatEventAt() < SAFETY_TIMEOUT_MS) { - setTimeout(checkStuck, 10_000); - return; - } - clearHistoryPoll(); - set({ - error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.', - sending: false, - activeRunId: null, - lastUserMessageAt: null, - }); - }; - setTimeout(checkStuck, 30_000); - - try { - const idempotencyKey = crypto.randomUUID(); - const hasMedia = attachments && attachments.length > 0; - if (hasMedia) { - console.log('[sendMessage] Media paths:', attachments!.map(a => a.stagedPath)); - } - - // Cache image attachments BEFORE the IPC call to avoid race condition: - // history may reload (via Gateway event) before the RPC returns. - // Keyed by staged file path which appears in [media attached: ...]. - if (hasMedia && attachments) { - for (const a of attachments) { - upsertImageCacheEntry(a.stagedPath, { - fileName: a.fileName, - mimeType: a.mimeType, - fileSize: a.fileSize, - preview: a.preview, - }); - } - } - - let result: ChatSendWithMediaResult; - - // Longer timeout for chat sends to tolerate high-latency networks (avoids connect error) - const CHAT_SEND_TIMEOUT_MS = 120_000; - - if (hasMedia) { - result = await hostApi.chat.sendWithMedia({ - sessionKey: currentSessionKey, - message: trimmed || 'Process the attached file(s).', - deliver: false, - idempotencyKey, - media: attachments.map((a) => ({ - filePath: a.stagedPath, - mimeType: a.mimeType, - fileName: a.fileName, - })), - }); - } else { - const rpcResult = await hostApi.gateway.rpc<{ runId?: string }>( - 'chat.send', - { - sessionKey: currentSessionKey, - message: trimmed, - deliver: false, - idempotencyKey, - }, - CHAT_SEND_TIMEOUT_MS, - ); - result = { success: true, result: rpcResult }; - } - - console.log(`[sendMessage] RPC result: success=${result.success}, runId=${result.result?.runId || 'none'}`); - - const returnedRunId = result.result?.runId; - if (returnedRunId && currentSendGeneration !== sendGeneration) { - // This send was stopped or superseded while the RPC was in flight. - // If the stop happened before activeRunId was known, narrow the - // wildcard abort marker to the concrete runId we just learned. - // Keep '*' while a newer send is still pending its runId so early - // events from that newer run cannot re-arm the UI before ownership - // is established. - const lastAbortedRunId = getLastAbortedRunId(); - if (!get().sending && (!lastAbortedRunId || lastAbortedRunId === '*' || lastAbortedRunId === returnedRunId)) { - setLastAbortedRunId(returnedRunId); - } - return; - } - - if (currentSendGeneration !== sendGeneration) return; - - if (!result.success) { - clearHistoryPoll(); - set({ error: result.error || 'Failed to send message', sending: false }); - } else if (returnedRunId && get().sending) { - set({ activeRunId: returnedRunId }); - // Now that we have a valid activeRunId for the new run, the - // activeRunId guard will filter stale events from the old run. - // Safe to clear the abort marker. - setLastAbortedRunId(null); - const blockedEvents = takeBlockedRunEvents(returnedRunId); - if (blockedEvents.length > 0) { - queueMicrotask(() => { - for (const blockedEvent of blockedEvents) { - get().handleChatEvent(blockedEvent); - } - }); - } - } - } catch (err) { - if (currentSendGeneration !== sendGeneration) return; - clearHistoryPoll(); - set({ error: String(err), sending: false }); - } - }, - - // ── Abort active run ── - - abortRun: async () => { - sendGeneration += 1; - clearHistoryPoll(); - clearErrorRecoveryTimer(); - const { currentSessionKey, activeRunId } = get(); - // Mark the run as aborted BEFORE clearing state, so the event handler - // rejects any lingering Gateway events from this run. Use wildcard '*' - // when activeRunId is not yet known (user stopped before chat.send - // returned a runId) to block ALL run events from re-arming sending. - setLastAbortedRunId(activeRunId || '*'); - set({ sending: false, activeRunId: null, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [] }); - set({ streamingTools: [] }); - - try { - await hostApi.gateway.rpc('chat.abort', { sessionKey: currentSessionKey }); - } catch (err) { - set({ error: String(err) }); - } - // Reload history to pick up final transcript state from Gateway, - // which resolves hasFinalReply and clears hasActiveExecutionGraph. - void get().loadHistory(true); - }, - - // ── Handle incoming chat events from Gateway ── - - }; -} diff --git a/src/stores/chat/runtime-ui-actions.ts b/src/stores/chat/runtime-ui-actions.ts deleted file mode 100644 index 4d16856c..00000000 --- a/src/stores/chat/runtime-ui-actions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; - -export function createRuntimeUiActions(set: ChatSet, get: ChatGet): Pick { - return { - // ── Refresh: reload history + sessions ── - - refresh: async () => { - const { loadHistory, loadSessions } = get(); - await Promise.all([loadHistory(), loadSessions()]); - }, - - clearError: () => { - const { runError, currentSessionKey, dismissedRunErrors } = get(); - set({ - error: null, - runError: null, - ...(runError - ? { dismissedRunErrors: { ...dismissedRunErrors, [currentSessionKey]: runError } } - : {}), - }); - }, - }; -} diff --git a/src/stores/chat/session-actions.ts b/src/stores/chat/session-actions.ts deleted file mode 100644 index a1e143ed..00000000 --- a/src/stores/chat/session-actions.ts +++ /dev/null @@ -1,585 +0,0 @@ -import { hostApi } from '@/lib/host-api'; -import { - findHiddenOpenClawHeartbeatSession, - isClawXDesktopSessionKey, - shouldIncludeSessionInSidebarList, -} from './session-key-utils'; -import { pickStartupSessionFallback } from './session-selection'; -import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'; -import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types'; -import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; - -import { - LABEL_FETCH_CONCURRENCY, - beginSessionLabelHydration, - clearSessionLabelHydrationTracking, - finishSessionLabelHydration, - getSessionLabelHydrationCandidate, - getSessionLabelHydrationRuntimeKey, - isSessionLabelHydrationReady, -} from './session-label-hydration'; - -function getAgentIdFromSessionKey(sessionKey: string): string { - if (!sessionKey.startsWith('agent:')) return 'main'; - const [, agentId] = sessionKey.split(':'); - return agentId || 'main'; -} - -function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null { - if (!sessionKey.startsWith('agent:')) return null; - const parts = sessionKey.split(':'); - if (parts.length < 2) return null; - return `${parts[0]}:${parts[1]}`; -} - -function toSessionLabel(text: string, maxLength = 50): string { - const trimmed = text.trim(); - if (!trimmed) return ''; - return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength)}…` : trimmed; -} - -function applySessionBackendLabels(set: ChatSet, sessions: ChatSession[]): void { - const labels = Object.fromEntries( - sessions - .filter((session) => !session.key.endsWith(':main')) - .map((session) => [session.key, toSessionLabel(session.label || session.derivedTitle || '')] as const) - .filter((entry): entry is [string, string] => Boolean(entry[1])), - ); - if (Object.keys(labels).length === 0) return; - set((state) => ({ - sessionLabels: { - ...state.sessionLabels, - ...Object.fromEntries( - Object.entries(labels).filter(([key]) => !state.sessionLabels[key]), - ), - }, - })); -} - -function parseSessionUpdatedAtMs(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) { - return toMs(value); - } - if (typeof value === 'string' && value.trim()) { - const parsed = Date.parse(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return undefined; -} - -function parseSessionStatus(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined; -} - -function sessionIndicatesIdle(session: ChatSession | undefined): boolean { - if (!session) return false; - if (session.hasActiveRun === false) return true; - return session.status === 'done' - || session.status === 'completed' - || session.status === 'finished' - || session.status === 'failed' - || session.status === 'error' - || session.status === 'aborted' - || session.status === 'cancelled'; -} - -function reconcileCurrentSessionIdleFromBackend(set: ChatSet, get: ChatGet, sessions: ChatSession[]): void { - const state = get(); - if (!state.sending && state.activeRunId == null && !state.pendingFinal) return; - - const current = sessions.find((session) => session.key === state.currentSessionKey); - if (!sessionIndicatesIdle(current)) return; - - // Avoid clearing a brand-new send from stale sessions.list metadata. The - // backend's session row must have been updated at or after the user message - // that armed the renderer run state. - if ( - state.lastUserMessageAt != null - && typeof current?.updatedAt === 'number' - && current.updatedAt < toMs(state.lastUserMessageAt) - ) { - return; - } - - set({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingToolImages: [], - }); -} - -export function createSessionActions( - set: ChatSet, - get: ChatGet, -): Pick { - return { - loadSessions: async () => { - try { - const data = await hostApi.gateway.rpc>( - 'sessions.list', - { - includeDerivedTitles: true, - includeLastMessage: true, - } - ); - - if (data) { - const rawSessions = Array.isArray(data.sessions) ? data.sessions : []; - const normalizedSessions: ChatSession[] = rawSessions.map((s: Record) => ({ - key: String(s.key || ''), - label: s.label ? String(s.label) : undefined, - displayName: s.displayName ? String(s.displayName) : undefined, - derivedTitle: s.derivedTitle ? String(s.derivedTitle) : undefined, - lastMessagePreview: s.lastMessagePreview ? String(s.lastMessagePreview) : undefined, - thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined, - model: s.model ? String(s.model) : undefined, - updatedAt: parseSessionUpdatedAtMs(s.updatedAt), - status: parseSessionStatus(s.status), - hasActiveRun: typeof s.hasActiveRun === 'boolean' ? s.hasActiveRun : undefined, - channel: s.lastChannel ? String(s.lastChannel) : undefined, - })); - const sessions = normalizedSessions.filter((s: ChatSession) => shouldIncludeSessionInSidebarList(s)); - - const canonicalBySuffix = new Map(); - for (const session of sessions) { - if (!session.key.startsWith('agent:')) continue; - const parts = session.key.split(':'); - if (parts.length < 3) continue; - const suffix = parts.slice(2).join(':'); - if (suffix && !canonicalBySuffix.has(suffix)) { - canonicalBySuffix.set(suffix, session.key); - } - } - - // Deduplicate: if both short and canonical existed, keep canonical only - const seen = new Set(); - const dedupedSessions = sessions.filter((s) => { - if (!s.key.startsWith('agent:') && canonicalBySuffix.has(s.key)) return false; - if (seen.has(s.key)) return false; - seen.add(s.key); - return true; - }); - - const { currentSessionKey, sessions: localSessions } = get(); - const localSessionByKey = new Map( - localSessions.map((session) => [session.key, session] as const), - ); - const mergedSessions = dedupedSessions.map((session) => { - const localSession = localSessionByKey.get(session.key); - const workspacePath = session.workspacePath ?? localSession?.workspacePath; - if (!localSession?.createdLocally && workspacePath === session.workspacePath) return session; - return { - ...session, - ...(workspacePath ? { workspacePath } : {}), - ...(localSession?.createdLocally ? { createdLocally: true } : {}), - }; - }); - let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY; - let replacedHiddenHeartbeatSession = false; - const hiddenCurrentSession = findHiddenOpenClawHeartbeatSession(nextSessionKey, normalizedSessions); - if (hiddenCurrentSession) { - const prefix = getCanonicalPrefixFromSessionKey(nextSessionKey) - ?? getCanonicalPrefixFromSessions(sessions) - ?? DEFAULT_CANONICAL_PREFIX; - nextSessionKey = `${prefix}:session-${Date.now()}`; - replacedHiddenHeartbeatSession = true; - } - if (!nextSessionKey.startsWith('agent:')) { - const canonicalMatch = canonicalBySuffix.get(nextSessionKey); - if (canonicalMatch) { - nextSessionKey = canonicalMatch; - } - } - if (!replacedHiddenHeartbeatSession && !mergedSessions.find((s) => s.key === nextSessionKey) && mergedSessions.length > 0) { - const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey); - if (!hasLocalPendingSession) { - const fallbackKey = pickStartupSessionFallback(nextSessionKey, mergedSessions); - if (fallbackKey) { - nextSessionKey = fallbackKey; - } - } - } - - const localCurrentSession = localSessions.find((session) => session.key === nextSessionKey); - const sessionsWithCurrent = !mergedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey - && isClawXDesktopSessionKey(nextSessionKey) - ? [ - ...mergedSessions, - localCurrentSession ?? { - key: nextSessionKey, - displayName: nextSessionKey, - ...(replacedHiddenHeartbeatSession ? { createdLocally: true } : {}), - }, - ] - : mergedSessions; - - const discoveredActivity = Object.fromEntries( - sessionsWithCurrent - .filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt)) - .map((session) => [session.key, session.updatedAt!]), - ); - - const sessionChanged = currentSessionKey !== nextSessionKey; - set((state) => ({ - sessions: sessionsWithCurrent, - currentSessionKey: nextSessionKey, - currentAgentId: getAgentIdFromSessionKey(nextSessionKey), - sessionLastActivity: { - ...state.sessionLastActivity, - ...discoveredActivity, - }, - ...(sessionChanged ? { - messages: [], - sending: false, - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - runError: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - } : {}), - })); - reconcileCurrentSessionIdleFromBackend(set, get, sessionsWithCurrent); - applySessionBackendLabels(set, sessionsWithCurrent); - - const gatewayRuntimeKey = getSessionLabelHydrationRuntimeKey(undefined); - const shouldHydrateSessionLabels = isSessionLabelHydrationReady(gatewayRuntimeKey, true); - - if (sessionChanged) { - get().loadHistory(); - } - - // Background: fetch first user message for every non-main session to populate labels. - // Concurrency-limited to avoid flooding the gateway with parallel RPCs. - // By the time this runs, the gateway should already be fully ready (Sidebar - // gates on gatewayReady), so no startup-retry loop is needed. - const sessionsToLabel = shouldHydrateSessionLabels - ? sessionsWithCurrent - .map((session) => ({ - session, - candidate: getSessionLabelHydrationCandidate( - session, - get().sessionLabels, - get().sessionLastActivity, - ), - })) - .filter((entry) => entry.candidate != null) - .map((entry) => ({ session: entry.session, version: entry.candidate!.version })) - : []; - if (sessionsToLabel.length > 0) { - void (async () => { - for (let i = 0; i < sessionsToLabel.length; i += LABEL_FETCH_CONCURRENCY) { - const batch = sessionsToLabel.slice(i, i + LABEL_FETCH_CONCURRENCY) - .filter(({ session, version }) => beginSessionLabelHydration(session.key, version)); - await Promise.all( - batch.map(async ({ session, version }) => { - try { - const result = await hostApi.gateway.rpc>( - 'chat.history', - { sessionKey: session.key, limit: 1000 }, - ); - const msgs = Array.isArray(result.messages) ? result.messages as RawMessage[] : []; - const firstUser = msgs.find((m) => m.role === 'user'); - const lastMsg = msgs[msgs.length - 1]; - const labelText = firstUser ? getMessageText(firstUser.content).trim() : ''; - set((s) => { - const next: Partial = {}; - if (labelText && !s.sessionLabels[session.key]?.trim()) { - const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText; - next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated }; - } - if (lastMsg?.timestamp) { - next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) }; - } - return next; - }); - finishSessionLabelHydration(session.key, version, labelText ? 'labeled' : 'empty'); - } catch { - finishSessionLabelHydration(session.key, version, 'error'); - } - }), - ); - } - })(); - } - } - } catch (err) { - console.warn('Failed to load sessions:', err); - } - }, - - // ── Switch session ── - - switchSession: (key: string) => { - const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); - // Only treat sessions with no history records and no activity timestamp as empty. - // Relying solely on messages.length is unreliable because switchSession clears - // the current messages before loadHistory runs, creating a race condition that - // could cause sessions with real history to be incorrectly removed from the sidebar. - const leavingEmpty = !currentSessionKey.endsWith(':main') - && messages.length === 0 - && !sessionLastActivity[currentSessionKey] - && !sessionLabels[currentSessionKey]; - set((s) => ({ - currentSessionKey: key, - currentAgentId: getAgentIdFromSessionKey(key), - messages: [], - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - ...(leavingEmpty ? { - sessions: s.sessions.filter((s) => s.key !== currentSessionKey), - sessionLabels: Object.fromEntries( - Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey), - ), - sessionLastActivity: Object.fromEntries( - Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey), - ), - } : {}), - })); - get().loadHistory(); - }, - - selectAcpSession: (key: string, workspacePath?: string) => { - const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); - const leavingEmpty = !currentSessionKey.endsWith(':main') - && messages.length === 0 - && !sessionLastActivity[currentSessionKey] - && !sessionLabels[currentSessionKey]; - set((s) => ({ - currentSessionKey: key, - currentAgentId: getAgentIdFromSessionKey(key), - messages: [], - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - sessions: (() => { - const sessions = leavingEmpty - ? s.sessions.filter((session) => session.key !== currentSessionKey) - : s.sessions; - const existingSession = sessions.find((session) => session.key === key); - if (!existingSession) { - return [...sessions, { key, displayName: key, ...(workspacePath ? { workspacePath } : {}) }]; - } - if (!workspacePath || existingSession.workspacePath === workspacePath) return sessions; - return sessions.map((session) => ( - session.key === key ? { ...session, workspacePath } : session - )); - })(), - ...(leavingEmpty ? { - sessionLabels: Object.fromEntries( - Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey), - ), - sessionLastActivity: Object.fromEntries( - Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey), - ), - } : {}), - })); - }, - - // ── Delete session ── - // - // NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent) - // RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase. - // Deletion is therefore performed locally: the renderer drops the session - // from the sidebar / labels / activity maps and the Main process hard-deletes - // the on-disk transcript so it stops appearing in sessions.list and stops - // contributing to the Dashboard token-usage history. - - deleteSession: async (key: string) => { - clearSessionLabelHydrationTracking(key); - clearPendingOptimisticUserMessages(key); - // Hard-delete the session's JSONL transcript on disk. - // The main process unlinks .jsonl plus any leftover - // .deleted.jsonl and .jsonl.reset.* siblings, then removes the - // entry from sessions.json so sessions.list stops surfacing it. - try { - const result = await hostApi.sessions.delete(key); - if (!result.success) { - console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error); - } - } catch (err) { - console.warn(`[deleteSession] IPC call failed for ${key}:`, err); - } - - const { currentSessionKey, sessions } = get(); - const remaining = sessions.filter((s) => s.key !== key); - - if (currentSessionKey === key) { - // Switched away from deleted session — pick the first remaining or create new - const next = remaining[0]; - set((s) => ({ - sessions: remaining, - sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)), - sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)), - messages: [], - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY, - currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY), - })); - if (next) { - get().loadHistory(); - } - } else { - set((s) => ({ - sessions: remaining, - sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)), - sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)), - })); - } - }, - - // ── New session ── - - newSession: () => { - // Generate a new unique session key and switch to it. - // NOTE: We intentionally do NOT call sessions.reset on the old session. - // sessions.reset archives (renames) the session JSONL file, making old - // conversation history inaccessible when the user switches back to it. - const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); - // Only treat sessions with no history records and no activity timestamp as empty - const leavingEmpty = !currentSessionKey.endsWith(':main') - && messages.length === 0 - && !sessionLastActivity[currentSessionKey] - && !sessionLabels[currentSessionKey]; - const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX; - const newKey = `${prefix}:session-${Date.now()}`; - const newSessionEntry: ChatSession = { key: newKey, displayName: newKey, createdLocally: true }; - set((s) => ({ - currentSessionKey: newKey, - currentAgentId: getAgentIdFromSessionKey(newKey), - sessions: [ - ...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions), - newSessionEntry, - ], - sessionLabels: leavingEmpty - ? Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey)) - : s.sessionLabels, - sessionLastActivity: leavingEmpty - ? Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey)) - : s.sessionLastActivity, - messages: [], - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - })); - }, - - acknowledgeAcpSessionCreated: (key: string, workspacePath?: string, initialPrompt?: string) => { - const normalizedWorkspacePath = workspacePath?.trim(); - const initialLabel = key.endsWith(':main') ? '' : toSessionLabel(initialPrompt || ''); - set((s) => { - const sessionEntry = s.sessions.find((session) => session.key === key); - return { - sessions: sessionEntry - ? s.sessions.map((session) => ( - session.key === key && session.createdLocally - ? { - ...session, - createdLocally: false, - ...(normalizedWorkspacePath ? { workspacePath: normalizedWorkspacePath } : {}), - } - : session - )) - : [...s.sessions, { - key, - displayName: key, - ...(normalizedWorkspacePath ? { workspacePath: normalizedWorkspacePath } : {}), - }], - ...(initialLabel && !sessionEntry?.label?.trim() && !s.sessionLabels[key] - ? { sessionLabels: { ...s.sessionLabels, [key]: initialLabel } } - : {}), - }; - }); - }, - - // ── Rename session ── - - renameSession: async (key: string, label: string) => { - const normalized = label.trim(); - if (!normalized) { - throw new Error('Session label cannot be empty'); - } - - // Persist the new label to sessions.json via IPC - try { - const result = await hostApi.sessions.rename(key, normalized); - if (!result.success) { - throw new Error(result.error || 'Failed to rename session'); - } - } catch (err) { - console.error(`[renameSession] IPC call failed for ${key}:`, err); - throw err; - } - - // Update local state: both sessions array and sessionLabels - set((s) => ({ - sessions: s.sessions.map((session) => - session.key === key ? { ...session, label: normalized } : session, - ), - sessionLabels: { ...s.sessionLabels, [key]: normalized }, - })); - }, - - // ── Cleanup empty session on navigate away ── - - cleanupEmptySession: () => { - const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); - // Only remove non-main sessions that were never used (no messages sent). - // This mirrors the "leavingEmpty" logic in switchSession so that creating - // a new session and immediately navigating away doesn't leave a ghost entry - // in the sidebar. - // Also check sessionLastActivity and sessionLabels comprehensively to prevent - // falsely treating sessions with history as empty due to switchSession clearing messages early. - const isEmptyNonMain = !currentSessionKey.endsWith(':main') - && messages.length === 0 - && !sessionLastActivity[currentSessionKey] - && !sessionLabels[currentSessionKey]; - if (!isEmptyNonMain) return; - set((s) => ({ - sessions: s.sessions.filter((sess) => sess.key !== currentSessionKey), - sessionLabels: Object.fromEntries( - Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey), - ), - sessionLastActivity: Object.fromEntries( - Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey), - ), - })); - }, - - // ── Load chat history ── - - }; -} diff --git a/src/stores/chat/session-history-actions.ts b/src/stores/chat/session-history-actions.ts deleted file mode 100644 index 5a39422d..00000000 --- a/src/stores/chat/session-history-actions.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; -import { createHistoryActions } from './history-actions'; -import { createSessionActions } from './session-actions'; - -export function createSessionHistoryActions(set: ChatSet, get: ChatGet): SessionHistoryActions { - return { - ...createSessionActions(set, get), - ...createHistoryActions(set, get), - }; -} diff --git a/src/stores/chat/store-api.ts b/src/stores/chat/store-api.ts deleted file mode 100644 index 1ca2930d..00000000 --- a/src/stores/chat/store-api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ChatState } from './types'; - -export type ChatSet = ( - partial: Partial | ((state: ChatState) => Partial), - replace?: false, -) => void; - -export type ChatGet = () => ChatState; - -export type SessionHistoryActions = Pick< - ChatState, - 'loadSessions' | 'switchSession' | 'selectAcpSession' | 'newSession' | 'acknowledgeAcpSessionCreated' | 'deleteSession' | 'renameSession' | 'cleanupEmptySession' | 'loadHistory' | 'loadMoreHistory' ->; - -export type RuntimeActions = Pick< - ChatState, - 'sendMessage' | 'abortRun' | 'handleChatEvent' | 'refresh' | 'clearError' ->; diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index e871ee29..9cc7b033 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -6,19 +6,11 @@ import { create } from 'zustand'; import { hostApi } from '@/lib/host-api'; import { hostEvents } from '@/lib/host-events'; import type { GatewayNotification, GatewayHealth, GatewayStatus } from '../types/gateway'; -import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events'; import type { GatewaySessionsChangedPayload } from './chat/session-catalog'; -import { getCronSessionBaseKey, sessionKeysAreEquivalent } from './chat/cron-session-utils'; let gatewayInitPromise: Promise | null = null; let gatewayEventUnsubscribers: Array<() => void> | null = null; let gatewayReconcileTimer: ReturnType | null = null; -const gatewayEventDedupe = new Map(); -const GATEWAY_EVENT_DEDUPE_TTL_MS = 30_000; -const LOAD_SESSIONS_MIN_INTERVAL_MS = 1_200; -const LOAD_HISTORY_MIN_INTERVAL_MS = 800; -let lastLoadSessionsAt = 0; -let lastLoadHistoryAt = 0; let cronRepairTriggeredThisSession = false; let lastSynchronizedRuntimeIdentity: string | null = null; let gatewaySessionGeneration = 0; @@ -38,143 +30,6 @@ interface GatewayState { clearError: () => void; } -function pruneGatewayEventDedupe(now: number): void { - for (const [key, ts] of gatewayEventDedupe) { - if (now - ts > GATEWAY_EVENT_DEDUPE_TTL_MS) { - gatewayEventDedupe.delete(key); - } - } -} - -function stableGatewayEventFingerprint(value: unknown): string { - let hash = 2166136261; - let length = 0; - - const add = (part: string): void => { - length += part.length; - for (let i = 0; i < part.length; i += 1) { - hash ^= part.charCodeAt(i); - hash = Math.imul(hash, 16777619) >>> 0; - } - }; - - const visit = (entry: unknown): void => { - if (entry === undefined) { - add('u:'); - return; - } - if (entry === null || typeof entry !== 'object') { - add(`${typeof entry}:${JSON.stringify(entry)};`); - return; - } - if (Array.isArray(entry)) { - add('['); - for (const item of entry) visit(item); - add(']'); - return; - } - - add('{'); - for (const [key, child] of Object.entries(entry as Record).sort(([left], [right]) => left.localeCompare(right))) { - add(`${JSON.stringify(key)}:`); - visit(child); - } - add('}'); - }; - - visit(value); - return `${hash.toString(36)}:${length.toString(36)}`; -} - -function buildGatewayEventDedupeKey(event: Record): string | null { - const runId = event.runId != null ? String(event.runId) : ''; - const sessionKey = event.sessionKey != null ? String(event.sessionKey) : ''; - const seq = event.seq != null ? String(event.seq) : ''; - const state = event.state != null ? String(event.state) : ''; - if (state === 'delta' && !seq) { - return ['delta-nosq', runId, sessionKey, stableGatewayEventFingerprint(event.message ?? event)].join('|'); - } - if (runId || sessionKey || seq || state) { - return [runId, sessionKey, seq, state].join('|'); - } - const message = event.message; - if (message && typeof message === 'object') { - const msg = message as Record; - const messageId = msg.id != null ? String(msg.id) : ''; - const stopReason = msg.stopReason ?? msg.stop_reason; - if (messageId || stopReason) { - return `msg|${messageId}|${String(stopReason ?? '')}`; - } - } - return null; -} - -function getMessageIdDedupeKey(event: Record): string | null { - const state = event.state != null ? String(event.state) : ''; - if (state !== 'final') return null; - const message = event.message; - if (message && typeof message === 'object') { - const msgId = (message as Record).id; - if (msgId != null) return `final-msgid|${String(msgId)}`; - } - return null; -} - -function shouldProcessGatewayEvent(event: Record): boolean { - const key = buildGatewayEventDedupeKey(event); - const msgKey = getMessageIdDedupeKey(event); - if (!key && !msgKey) return true; - const now = Date.now(); - pruneGatewayEventDedupe(now); - if ((key && gatewayEventDedupe.has(key)) || (msgKey && gatewayEventDedupe.has(msgKey))) { - return false; - } - if (key) gatewayEventDedupe.set(key, now); - if (msgKey) gatewayEventDedupe.set(msgKey, now); - return true; -} - -function maybeLoadSessions( - state: { loadSessions: () => Promise }, - force = false, -): void { - const { status } = useGatewayStore.getState(); - if (status.gatewayReady === false) return; - - const now = Date.now(); - if (!force && now - lastLoadSessionsAt < LOAD_SESSIONS_MIN_INTERVAL_MS) return; - lastLoadSessionsAt = now; - void state.loadSessions(); -} - -function maybeLoadHistory( - state: { loadHistory: (quiet?: boolean) => Promise }, - force = false, -): void { - const now = Date.now(); - if (!force && now - lastLoadHistoryAt < LOAD_HISTORY_MIN_INTERVAL_MS) return; - lastLoadHistoryAt = now; - void state.loadHistory(true); -} - -/** Bump sidebar ordering when any session receives gateway traffic (e.g. Feishu DM). */ -function touchSessionActivity(sessionKey: string | null | undefined, activityMs = Date.now()): void { - if (!sessionKey) return; - // Cron runs stream under the run-scoped key; the sidebar only carries the - // base cron entry, so normalize before bumping activity. - const activityKey = getCronSessionBaseKey(sessionKey); - import('./chat') - .then(({ useChatStore }) => { - useChatStore.setState((state) => ({ - sessionLastActivity: { - ...state.sessionLastActivity, - [activityKey]: Math.max(state.sessionLastActivity[activityKey] ?? 0, activityMs), - }, - })); - }) - .catch(() => {}); -} - function getGatewayErrorMessage(payload: string | { message?: string }): string { if (typeof payload === 'string') return payload || 'Gateway error'; return payload.message || 'Gateway error'; @@ -244,113 +99,6 @@ function handleGatewayNotification(notification: GatewayNotification | undefined .catch(() => {}); return; } - - const p = asRecord(payload.params); - const data = asRecord(p.data); - const phase = data.phase ?? p.phase; - const hasChatData = (p.state ?? data.state) || (p.message ?? data.message); - - if (hasChatData) { - const normalizedEvent: Record = { - ...data, - runId: p.runId ?? data.runId, - sessionKey: p.sessionKey ?? data.sessionKey, - stream: p.stream ?? data.stream, - seq: p.seq ?? data.seq, - state: p.state ?? data.state, - message: p.message ?? data.message, - }; - if (shouldProcessGatewayEvent(normalizedEvent)) { - import('./chat') - .then(({ useChatStore }) => { - useChatStore.getState().handleChatEvent(normalizedEvent); - }) - .catch(() => {}); - } - } - - if (phase === 'run.started' || phase === 'run.ended') { - const sessionKey = typeof (p.sessionKey ?? data.sessionKey) === 'string' - ? String(p.sessionKey ?? data.sessionKey) - : undefined; - touchSessionActivity(sessionKey); - } -} - -function handleChatRuntimeEvent(event: ChatRuntimeEvent): void { - const resolvedSessionKey = event.sessionKey ?? null; - if (resolvedSessionKey) { - touchSessionActivity(resolvedSessionKey, typeof event.ts === 'number' ? event.ts : Date.now()); - } - - import('./chat') - .then(({ useChatStore, syncCachedSessionRunIdle }) => { - const state = useChatStore.getState(); - state.handleRuntimeEvent(event); - - // Cron runs stream under the run-scoped key; treat it as the equivalent - // base cron session the user is viewing instead of an unknown session. - const matchesCurrentSession = resolvedSessionKey != null - && sessionKeysAreEquivalent(resolvedSessionKey, state.currentSessionKey); - const matchesActiveRun = state.activeRunId != null && event.runId === state.activeRunId; - const isKnownSession = resolvedSessionKey != null && state.sessions.some( - (session) => sessionKeysAreEquivalent(session.key, resolvedSessionKey), - ); - const shouldRefreshSessions = resolvedSessionKey != null - && !matchesCurrentSession - && !isKnownSession; - - if (event.type === 'run.started') { - if (shouldRefreshSessions) { - maybeLoadSessions(state, true); - } - // Surface the freshly-written cron trigger message so the Execution - // Graph has a run segment to anchor its live steps to. - if (matchesCurrentSession) { - maybeLoadHistory(state, true); - } - return; - } - - if (event.type !== 'run.ended') { - return; - } - - if (shouldRefreshSessions) { - maybeLoadSessions(state, true); - } - - if (matchesCurrentSession || matchesActiveRun) { - maybeLoadHistory(state, true); - } - if (resolvedSessionKey && !matchesCurrentSession) { - syncCachedSessionRunIdle(resolvedSessionKey); - } - }) - .catch(() => {}); -} - -function handleGatewayChatMessage(data: unknown): void { - import('./chat').then(({ useChatStore }) => { - const chatData = data as Record; - const payload = ('message' in chatData && typeof chatData.message === 'object') - ? chatData.message as Record - : chatData; - - if (payload.state) { - if (!shouldProcessGatewayEvent(payload)) return; - useChatStore.getState().handleChatEvent(payload); - return; - } - - const normalized = { - state: 'final', - message: payload, - runId: chatData.runId ?? payload.runId, - }; - if (!shouldProcessGatewayEvent(normalized)) return; - useChatStore.getState().handleChatEvent(normalized); - }).catch(() => {}); } function mapChannelStatus(status: string): 'connected' | 'connecting' | 'disconnected' | 'error' { @@ -424,12 +172,6 @@ export const useGatewayStore = create((set, get) => ({ const current = get().health; set({ health: { ...(current ?? { ok: true }), presence: payload } }); })); - unsubscribers.push(hostEvents.onGatewayChatMessage((payload) => { - handleGatewayChatMessage(payload); - })); - unsubscribers.push(hostEvents.onChatRuntimeEvent((payload) => { - handleChatRuntimeEvent(payload); - })); unsubscribers.push(hostEvents.onGatewayChannelStatus( (update) => { import('./channels') diff --git a/src/styles/globals.css b/src/styles/globals.css index a74c06eb..e1c2d48e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -69,9 +69,6 @@ * ───────────────────────────────────────────────────────────────────── * bg-black/30 dark:bg-black/60 (canonical: components/ui/sheet.tsx) * - * Image lightbox may use bg-black/80 when a darker scrim is intentional - * (see pages/Chat/ChatMessage.tsx). - * * STATUS COLOURS (success / warning / error) * ───────────────────────────────────────────────────────────────────── * icon + inline text : text-{green|yellow|red}-700 dark:text-…-400 @@ -511,6 +508,45 @@ margin: 0.6rem 0 0.35rem; } +/* Streamdown's root `space-y-4` replaces both margins on sibling blocks. These + * scoped rules restore ClawX's prose rhythm while preserving edge resets. */ +.prose.clawx-markdown.clawx-streamdown > p { + margin: 0 0 0.75em; +} + +.prose.clawx-streamdown > [data-streamdown='heading-1'] { + margin: 1rem 0 0.6rem; +} + +.prose.clawx-streamdown > [data-streamdown='heading-2'] { + margin: 0.9rem 0 0.5rem; +} + +.prose.clawx-streamdown > [data-streamdown='heading-3'] { + margin: 0.8rem 0 0.45rem; +} + +.prose.clawx-streamdown > [data-streamdown='heading-4'] { + margin: 0.7rem 0 0.4rem; +} + +.prose.clawx-streamdown > [data-streamdown='heading-5'], +.prose.clawx-streamdown > [data-streamdown='heading-6'] { + margin: 0.6rem 0 0.35rem; +} + +.prose.clawx-streamdown > [data-streamdown='horizontal-rule'] { + margin: 1rem 0; +} + +.prose.clawx-markdown.clawx-streamdown > :first-child { + margin-top: 0; +} + +.prose.clawx-markdown.clawx-streamdown > :last-child { + margin-bottom: 0; +} + .prose pre { margin-top: 0.5em; margin-bottom: 0.5em; @@ -545,6 +581,86 @@ background: hsl(var(--muted)); } +.clawx-streamdown [data-streamdown='code-block'] { + @apply relative my-2 gap-0 overflow-hidden rounded-lg border-0 bg-surface-input p-0; +} + +.clawx-streamdown [data-streamdown='code-block-header'] { + @apply h-7 justify-end px-3 pr-10; +} + +.clawx-streamdown [data-streamdown='code-block-header'] > span { + @apply ml-0; +} + +.clawx-streamdown [data-streamdown='code-block'] > div:has(> [data-streamdown='code-block-actions']) { + @apply absolute right-0 top-0 mt-0 h-7 items-center pr-2; +} + +.clawx-streamdown [data-streamdown='code-block-actions'] { + @apply border-0 bg-transparent p-0 shadow-none backdrop-blur-none; +} + +.clawx-streamdown [data-streamdown='code-block-body'] { + @apply min-w-0 overflow-x-auto; +} + +.clawx-streamdown [data-streamdown='code-block-body'] pre { + @apply max-w-full overflow-x-auto whitespace-pre-wrap bg-transparent; + overflow-wrap: break-word; +} + +.clawx-streamdown [data-streamdown='code-block-body'] code { + @apply whitespace-pre-wrap bg-transparent font-mono text-sm; + overflow-wrap: break-word; +} + +.clawx-streamdown [data-streamdown='code-block-body'] code > span { + @apply block; +} + +.prose.clawx-markdown.clawx-streamdown [data-streamdown='unordered-list'], +.prose.clawx-markdown.clawx-streamdown [data-streamdown='ordered-list'] { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.clawx-streamdown [data-streamdown='list-item'] { + @apply my-0 py-0.5; +} + +.clawx-streamdown [data-streamdown='list-item'] > p { + @apply my-0; +} + +.clawx-streamdown [data-streamdown='table-wrapper'], +.clawx-streamdown [data-streamdown='table-wrapper'] > div, +.clawx-streamdown [data-streamdown='table'] { + @apply border-0; +} + +.clawx-streamdown [data-streamdown='table-wrapper'] { + @apply p-0; +} + +.clawx-streamdown [data-streamdown='table-wrapper'] > div { + @apply rounded-none; +} + +.clawx-streamdown [data-streamdown='table'] { + @apply my-0; +} + +.clawx-streamdown [data-streamdown='table-header'], +.clawx-streamdown [data-streamdown='table-header-cell'] { + @apply bg-transparent font-bold; +} + +.dark .clawx-streamdown [data-streamdown='table-header'], +.dark .clawx-streamdown [data-streamdown='table-header-cell'] { + @apply bg-muted; +} + /* Typing indicator animation */ @keyframes bounce { diff --git a/tailwind.config.js b/tailwind.config.js index c22d6960..ffcc88cd 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -44,6 +44,10 @@ module.exports = { content: [ './index.html', './src/**/*.{js,ts,jsx,tsx}', + './node_modules/streamdown/dist/*.js', + './node_modules/@streamdown/code/dist/*.js', + './node_modules/@streamdown/math/dist/*.js', + './node_modules/@streamdown/cjk/dist/*.js', ], theme: { container: { diff --git a/tests/e2e/chat-acp-inline-timeline.spec.ts b/tests/e2e/chat-acp-inline-timeline.spec.ts index b5034456..1cea0639 100644 --- a/tests/e2e/chat-acp-inline-timeline.spec.ts +++ b/tests/e2e/chat-acp-inline-timeline.spec.ts @@ -1,5 +1,12 @@ import type { ElectronApplication } from '@playwright/test'; -import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; +import { + closeElectronApp, + expect, + getRecordedHostInvocations, + getStableWindow, + installIpcMocks, + test, +} from './fixtures/electron'; import { expandAcpToolCallsGroup } from './fixtures/acp-timeline'; const MAIN_SESSION_KEY = 'agent:main:main'; @@ -70,6 +77,7 @@ async function installAcpChatMocks( }, }, hostApi: baseHostApiMocks(loadResult), + recordHostInvocations: true, }); } @@ -383,6 +391,27 @@ async function openChat(app: ElectronApplication) { } test.describe('ClawX ACP inline timeline', () => { + test('does not use legacy history on startup or current-session clicks', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + await installAcpChatMocks(app); + const page = await openChat(app); + await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 }); + + await page.getByTestId(`sidebar-session-${MAIN_SESSION_KEY}`).click(); + await page.waitForTimeout(100); + + expect((await getRecordedHostInvocations(app)).some((call) => ( + call.module === 'gateway' + && call.action === 'rpc' + && call.payload?.method === 'chat.history' + ))).toBe(false); + } finally { + await closeElectronApp(app); + } + }); + test('supplements an ACP-replayed assistant turn with historical duration', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); @@ -508,7 +537,7 @@ test.describe('ClawX ACP inline timeline', () => { } }); - test('renders ACP tool updates inline without the legacy execution graph', async ({ launchElectronApp }) => { + test('renders ACP tool updates inline', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -533,7 +562,6 @@ test.describe('ClawX ACP inline timeline', () => { ]); await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); await expect(page.getByTestId('acp-tool-call-card')).toBeVisible(); await expect(page.getByTestId('acp-tool-call-card')).toContainText('Read package.json'); await expect(page.getByTestId('acp-tool-call-card')).toContainText('Loaded package metadata'); diff --git a/tests/e2e/chat-task-visualizer.spec.ts b/tests/e2e/chat-acp-process-timeline.spec.ts similarity index 95% rename from tests/e2e/chat-task-visualizer.spec.ts rename to tests/e2e/chat-acp-process-timeline.spec.ts index 66a6d769..98da1799 100644 --- a/tests/e2e/chat-task-visualizer.spec.ts +++ b/tests/e2e/chat-acp-process-timeline.spec.ts @@ -167,7 +167,6 @@ test.describe('ClawX ACP chat timeline', () => { ]); await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); await expect(page.getByTestId('acp-thought-block')).toContainText('Need to inspect the current implementation first.'); await expect(page.getByTestId('acp-tool-call-card')).toContainText('Read file'); await expect(page.getByTestId('acp-tool-call-card')).toContainText('Loaded src/pages/Chat/index.tsx'); @@ -206,7 +205,6 @@ test.describe('ClawX ACP chat timeline', () => { ]); await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); await expect(page.getByText(longRunProcessSegments[0], { exact: true })).toBeVisible(); await expect(page.getByText(longRunProcessSegments.at(-1)!, { exact: true })).toBeVisible(); await expect(page.getByText(longRunSummary, { exact: true })).toBeVisible(); @@ -216,7 +214,7 @@ test.describe('ClawX ACP chat timeline', () => { } }); - test('surfaces ACP load errors and does not render stale graph thinking state', async ({ launchElectronApp }) => { + test('surfaces ACP load errors and allows a retry', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -226,9 +224,6 @@ test.describe('ClawX ACP chat timeline', () => { const errorBanner = page.getByTestId('acp-error-banner'); await expect(errorBanner).toBeVisible({ timeout: 30_000 }); await expect(errorBanner).toContainText('404 Resource not found'); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); - await expect(page.getByTestId('chat-execution-step-thinking-trailing')).toHaveCount(0); - await page.getByRole('button', { name: 'Dismiss' }).click(); await expect(errorBanner).toHaveCount(0); await expect(page.getByText('404 Resource not found')).toHaveCount(0); diff --git a/tests/e2e/chat-file-changes.spec.ts b/tests/e2e/chat-file-changes.spec.ts index 1dcaf172..ea9a26fe 100644 --- a/tests/e2e/chat-file-changes.spec.ts +++ b/tests/e2e/chat-file-changes.spec.ts @@ -157,10 +157,6 @@ async function installFileActivityMocks(app: ElectronApplication, options: { })), }, }, - [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: { - success: true, - result: { messages: [] }, - }, }, hostApi, hostApiErrors: options.scopedReadError ? { [scopedReadKey]: options.scopedReadError } : undefined, diff --git a/tests/e2e/chat-history-rpc.ts b/tests/e2e/chat-history-rpc.ts deleted file mode 100644 index bda5dc37..00000000 --- a/tests/e2e/chat-history-rpc.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { DEFAULT_CHAT_HISTORY_MAX_CHARS } from '@/stores/chat/history-rpc-params'; - -export function chatHistoryRpcParams(sessionKey: string, limit: number) { - return { - sessionKey, - limit, - maxChars: DEFAULT_CHAT_HISTORY_MAX_CHARS, - }; -} - -export { DEFAULT_CHAT_HISTORY_MAX_CHARS as CHAT_HISTORY_MAX_CHARS }; diff --git a/tests/e2e/chat-history-startup-retry.spec.ts b/tests/e2e/chat-history-startup-retry.spec.ts deleted file mode 100644 index 1a13c384..00000000 --- a/tests/e2e/chat-history-startup-retry.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { ElectronApplication } from '@playwright/test'; -import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; - -const SESSION_KEY = 'agent:main:main'; -const MAIN_WORKSPACE = '/workspace'; -const DEFAULT_WORKSPACE = '~/.openclaw/workspace'; - -type AcpSessionUpdate = Record & { sessionUpdate: string }; - -function stableStringify(value: unknown): string { - if (value == null || typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; - const entries = Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); - return `{${entries.join(',')}}`; -} - -async function installAcpStartupMocks(app: ElectronApplication) { - await installIpcMocks(app, { - gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345, connectedAt: Date.now() }, - gatewayRpc: { - [stableStringify(['sessions.list', {}])]: { - success: true, - result: { - sessions: [{ key: SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE }], - }, - }, - }, - hostApi: { - [stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: MAIN_WORKSPACE, cwd: MAIN_WORKSPACE }])]: { - success: true, - generation: 1, - }, - [stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: MAIN_WORKSPACE, cwd: MAIN_WORKSPACE, createIfMissing: true }])]: { - success: true, - generation: 1, - }, - [stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE }])]: { - success: true, - generation: 1, - }, - [stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE, createIfMissing: true }])]: { - success: true, - generation: 1, - }, - [stableStringify(['/api/gateway/status', 'GET'])]: { - ok: true, - data: { - status: 200, - ok: true, - json: { state: 'running', gatewayReady: true, port: 18789, pid: 12345, connectedAt: Date.now() }, - }, - }, - [stableStringify(['/api/agents', 'GET'])]: { - ok: true, - data: { - status: 200, - ok: true, - json: { - success: true, - agents: [{ id: 'main', name: 'main', workspace: MAIN_WORKSPACE, mainSessionKey: SESSION_KEY }], - }, - }, - }, - }, - }); -} - -async function emitAcpSessionUpdates(app: ElectronApplication, updates: AcpSessionUpdate[], historical = true) { - await app.evaluate( - async ({ app: _app }, payload) => { - const { BrowserWindow } = process.mainModule!.require('electron') as typeof import('electron'); - for (const update of payload.updates) { - for (const window of BrowserWindow.getAllWindows()) { - window.webContents.send('chat:acp-session-update', { - sessionKey: payload.sessionKey, - generation: 1, - ...(payload.historical ? { historical: true } : {}), - notification: { - sessionId: payload.sessionKey, - update, - }, - }); - } - } - }, - { sessionKey: SESSION_KEY, updates, historical }, - ); -} - -async function openChat(app: ElectronApplication) { - const page = await getStableWindow(app); - try { - await page.reload(); - } catch (error) { - if (!String(error).includes('ERR_FILE_NOT_FOUND')) { - throw error; - } - } - await expect(page.getByTestId('main-layout')).toBeVisible(); - await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 }); - return page; -} - -test.describe('ClawX startup ACP history recovery', () => { - test('renders historical ACP messages after startup session load', async ({ launchElectronApp }) => { - const app = await launchElectronApp({ skipSetup: true }); - - try { - await installAcpStartupMocks(app); - const page = await openChat(app); - - await emitAcpSessionUpdates(app, [ - { - sessionUpdate: 'user_message', - messageId: 'startup-user', - content: [{ type: 'text', text: 'hello' }], - }, - { - sessionUpdate: 'agent_message', - messageId: 'startup-assistant', - content: [{ type: 'text', text: 'history restored from ACP replay' }], - }, - ]); - - await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId('acp-user-message')).toContainText('hello'); - await expect(page.getByTestId('acp-assistant-message')).toContainText('history restored from ACP replay'); - await expect(page.getByText('RPC timeout: chat.history')).toHaveCount(0); - } finally { - await closeElectronApp(app); - } - }); - - test('renders ACP updates that arrive after an initially empty startup load', async ({ launchElectronApp }) => { - const app = await launchElectronApp({ skipSetup: true }); - - try { - await installAcpStartupMocks(app); - const page = await openChat(app); - - await emitAcpSessionUpdates(app, [ - { - sessionUpdate: 'agent_message', - messageId: 'delayed-assistant', - content: [{ type: 'text', text: 'gateway authoritative ACP history after delay' }], - }, - ], false); - - await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText('gateway authoritative ACP history after delay')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText('RPC timeout: chat.history')).toHaveCount(0); - } finally { - await closeElectronApp(app); - } - }); -}); diff --git a/tests/e2e/chat-model-picker.spec.ts b/tests/e2e/chat-model-picker.spec.ts index 2200642e..7ec8bfd3 100644 --- a/tests/e2e/chat-model-picker.spec.ts +++ b/tests/e2e/chat-model-picker.spec.ts @@ -59,9 +59,6 @@ test.describe('ClawX chat model picker', () => { if (method === 'sessions.list') { return { success: true, result: { sessions: [{ key: 'agent:main:main', displayName: 'main' }] } }; } - if (method === 'chat.history') { - return { success: true, result: { messages: [] } }; - } return { success: true, result: {} }; }); @@ -108,9 +105,6 @@ test.describe('ClawX chat model picker', () => { if (method === 'sessions.list') { return makeResponse(request.id, { success: true, result: { sessions: [{ key: 'agent:main:main', displayName: 'main' }] } }); } - if (method === 'chat.history') { - return makeResponse(request.id, { success: true, result: { messages: [] } }); - } return makeResponse(request.id, { success: true, result: {} }); } if (request?.module === 'agents' && request.action === 'list') { diff --git a/tests/e2e/chat-new-session-date.spec.ts b/tests/e2e/chat-new-session-date.spec.ts index 0cd279a0..3aab8b69 100644 --- a/tests/e2e/chat-new-session-date.spec.ts +++ b/tests/e2e/chat-new-session-date.spec.ts @@ -147,14 +147,6 @@ test.describe('ClawX chat workspace session list', () => { success: true, result: { sessions }, }, - [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: { - success: true, - result: { messages: [] }, - }, - [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: { - success: true, - result: { messages: [] }, - }, }, hostApi: { [stableStringify(['/api/gateway/status', 'GET'])]: { @@ -223,11 +215,6 @@ test.describe('ClawX chat workspace session list', () => { const app = await launchElectronApp({ skipSetup: true }); const oldTimestampMs = Date.now() - 35 * 24 * 60 * 60 * 1000; const prompt = 'Investigate the sidebar title race'; - const seededHistory = [ - { role: 'user', content: 'Existing conversation', timestamp: oldTimestampMs }, - { role: 'assistant', content: 'Existing reply', timestamp: oldTimestampMs + 1000 }, - ]; - try { await installIpcMocks(app, { gatewayStatus: { state: 'running', port: 18789, pid: 12345 }, @@ -242,14 +229,6 @@ test.describe('ClawX chat workspace session list', () => { }], }, }, - [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: { - success: true, - result: { messages: seededHistory }, - }, - [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: { - success: true, - result: { messages: seededHistory }, - }, }, hostApi: { [stableStringify(['/api/gateway/status', 'GET'])]: { @@ -362,10 +341,6 @@ test.describe('ClawX chat workspace session list', () => { ], }, }, - [stableStringify(['chat.history', null])]: { - success: true, - result: { messages: [] }, - }, }, hostApi: { [stableStringify(['/api/gateway/status', 'GET'])]: { diff --git a/tests/e2e/chat-run-state-events.spec.ts b/tests/e2e/chat-run-state-events.spec.ts index bb94d637..67635f72 100644 --- a/tests/e2e/chat-run-state-events.spec.ts +++ b/tests/e2e/chat-run-state-events.spec.ts @@ -159,7 +159,7 @@ async function openChat(app: ElectronApplication) { } test.describe('ClawX chat run state events', () => { - test('renders ACP tool run-state updates inline without the legacy execution graph', async ({ launchElectronApp }) => { + test('renders ACP tool run-state updates inline', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -197,7 +197,6 @@ test.describe('ClawX chat run state events', () => { ]); await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); await expect(page.getByTestId('acp-thought-block')).toContainText('Need to inspect a file before answering.'); await expect(page.getByTestId('acp-tool-call-card')).toContainText('Read /tmp/demo.md'); await expect(page.getByTestId('acp-tool-call-card')).toContainText('Read complete'); @@ -230,7 +229,6 @@ test.describe('ClawX chat run state events', () => { await expect(page.getByTestId('acp-image-part')).toBeVisible(); await expect(page.getByTestId('acp-image-part').locator('img')).toBeVisible(); await expect(page.getByTestId('image-preview-unavailable')).toHaveCount(0); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -264,7 +262,6 @@ test.describe('ClawX chat run state events', () => { ]); await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); const toolCard = page.getByTestId('acp-tool-call-card'); await expect(toolCard).toContainText('Send generated image'); if (await toolCard.getAttribute('data-expanded') !== 'true') { @@ -339,7 +336,6 @@ test.describe('ClawX chat run state events', () => { await expect(image).toHaveAttribute('src', ONE_PIXEL_PNG_DATA_URL); await expect(page.getByTestId('chat-composer-image-generation-indicator')).toHaveCount(0); await expect(page.getByTestId('image-preview-unavailable')).toHaveCount(0); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -448,7 +444,6 @@ test.describe('ClawX chat run state events', () => { const image = timeline.getByRole('img', { name: 'Image' }); await expect(image).toBeVisible(); await expect(image).toHaveAttribute('src', ONE_PIXEL_PNG_DATA_URL); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -498,7 +493,6 @@ test.describe('ClawX chat run state events', () => { const image = timeline.getByRole('img', { name: 'Image' }); await expect(image).toBeVisible(); await expect(image).toHaveAttribute('src', ONE_PIXEL_PNG_DATA_URL); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -550,7 +544,6 @@ test.describe('ClawX chat run state events', () => { await expect(page.getByText(`MEDIA: ${untrustedPath}`)).toBeVisible(); await expect(timeline.getByTestId('acp-image-part')).toHaveCount(0); await expect(timeline.getByRole('img', { name: 'Image' })).toHaveCount(0); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -603,7 +596,6 @@ test.describe('ClawX chat run state events', () => { await expect(svgCard).toBeEnabled(); await expect(svgCard).toContainText(filePath); await expect(page.getByTestId('acp-image-part').locator('img')).toBeVisible(); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); } finally { await closeElectronApp(app); } diff --git a/tests/e2e/chat-sidebar-session-attention.spec.ts b/tests/e2e/chat-sidebar-session-attention.spec.ts index f838e090..a91be225 100644 --- a/tests/e2e/chat-sidebar-session-attention.spec.ts +++ b/tests/e2e/chat-sidebar-session-attention.spec.ts @@ -70,16 +70,6 @@ async function installSessionAttentionMocks(app: ElectronApplication): Promise [ - [stableStringify(['chat.history', { sessionKey, limit: 200, maxChars: 500000 }]), { - success: true, - result: { messages: [] }, - }], - [stableStringify(['chat.history', { sessionKey, limit: 1000, maxChars: 500000 }]), { - success: true, - result: { messages: [] }, - }], - ])), }, hostApi: { [stableStringify(['settings', 'getAll', null])]: { diff --git a/tests/e2e/chat-skill-trigger-i18n.spec.ts b/tests/e2e/chat-skill-trigger-i18n.spec.ts index f28dc691..bce04011 100644 --- a/tests/e2e/chat-skill-trigger-i18n.spec.ts +++ b/tests/e2e/chat-skill-trigger-i18n.spec.ts @@ -37,14 +37,6 @@ test.describe('ClawX chat skill trigger', () => { sessions: [{ key: SESSION_KEY, displayName: 'main' }], }, }, - [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: { - success: true, - result: { messages: [] }, - }, - [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: { - success: true, - result: { messages: [] }, - }, }, hostApi: { [stableStringify(['/api/gateway/status', 'GET'])]: { @@ -153,14 +145,6 @@ test.describe('ClawX chat skill trigger', () => { sessions: [{ key: SESSION_KEY, displayName: 'main' }], }, }, - [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: { - success: true, - result: { messages: [] }, - }, - [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: { - success: true, - result: { messages: [] }, - }, }, hostApi: { [stableStringify(['/api/gateway/status', 'GET'])]: { diff --git a/tests/e2e/chat-streamdown-rendering.spec.ts b/tests/e2e/chat-streamdown-rendering.spec.ts new file mode 100644 index 00000000..8253b528 --- /dev/null +++ b/tests/e2e/chat-streamdown-rendering.spec.ts @@ -0,0 +1,269 @@ +import type { ElectronApplication } from '@playwright/test'; +import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +const SESSION_KEY = 'agent:main:main'; +const MAIN_WORKSPACE = '/workspace'; +const DEFAULT_WORKSPACE = '~/.openclaw/workspace'; + +type AcpSessionUpdate = Record & { sessionUpdate: string }; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +async function emitAcpSessionUpdates( + app: ElectronApplication, + updates: AcpSessionUpdate[], + historical = false, +) { + await app.evaluate( + async ({ app: _app }, payload) => { + const { BrowserWindow } = process.mainModule!.require('electron') as typeof import('electron'); + for (const update of payload.updates) { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('chat:acp-session-update', { + sessionKey: payload.sessionKey, + generation: 1, + ...(payload.historical ? { historical: true } : {}), + notification: { + sessionId: payload.sessionKey, + update, + }, + }); + } + } + }, + { sessionKey: SESSION_KEY, updates, historical }, + ); +} + +async function deferAcpPrompt(app: ElectronApplication) { + await app.evaluate(async ({ app: _app }) => { + const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); + type HostRequest = { id?: string; module?: string; action?: string }; + type HostInvokeHandler = (event: unknown, request: HostRequest) => Promise; + const currentHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map; + })._invokeHandlers?.get('host:invoke'); + const pending = globalThis as typeof globalThis & { resolveStreamdownPrompt?: () => void }; + + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event: unknown, request: HostRequest) => { + if (request?.module === 'chat' && request.action === 'sendAcpPrompt') { + return await new Promise((resolve) => { + pending.resolveStreamdownPrompt = () => resolve({ + id: request.id, + ok: true, + data: { success: true, generation: 1 }, + }); + }); + } + return currentHostInvoke?.(event, request) ?? { ok: true, data: {} }; + }); + }); +} + +async function resolveAcpPrompt(app: ElectronApplication) { + await app.evaluate(async ({ app: _app }) => { + const pending = globalThis as typeof globalThis & { resolveStreamdownPrompt?: () => void }; + const resolvePrompt = pending.resolveStreamdownPrompt; + if (!resolvePrompt) throw new Error('Deferred Streamdown prompt was not pending'); + delete pending.resolveStreamdownPrompt; + resolvePrompt(); + }); +} + +test.describe('ClawX streaming Markdown rendering', () => { + test('repairs and animates only the pending assistant response, then clears animation markers', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + gatewayRpc: { + [stableStringify(['sessions.list', {}])]: { + success: true, + result: { sessions: [{ key: SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE }] }, + }, + }, + hostApi: { + [stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: MAIN_WORKSPACE, cwd: MAIN_WORKSPACE }])]: { + success: true, + generation: 1, + }, + [stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: MAIN_WORKSPACE, cwd: MAIN_WORKSPACE, createIfMissing: true }])]: { + success: true, + generation: 1, + }, + [stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE }])]: { + success: true, + generation: 1, + }, + [stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE, createIfMissing: true }])]: { + success: true, + generation: 1, + }, + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, agents: [{ id: 'main', name: 'main', workspace: MAIN_WORKSPACE, mainSessionKey: SESSION_KEY }] }, + }, + }, + }, + }); + await deferAcpPrompt(app); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) throw error; + } + + await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 }); + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message', + messageId: 'completed-assistant', + content: [{ type: 'text', text: 'Earlier completed answer.' }], + }], true); + await expect(page.getByText('Earlier completed answer.')).toBeVisible({ timeout: 30_000 }); + + await page.getByTestId('chat-composer-input').fill('Stream a Markdown response'); + await page.getByTestId('chat-composer-send').click(); + await expect(page.getByTestId('chat-composer-send')).toHaveAttribute('title', 'Stop'); + + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message_chunk', + messageId: 'streamdown-assistant', + content: { type: 'text', text: 'Streaming **bold' }, + }]); + + const activeMessage = page.getByTestId('acp-assistant-message').filter({ hasText: 'Streaming' }); + const completedMessage = page.getByTestId('acp-assistant-message').filter({ hasText: 'Earlier completed answer.' }); + await expect(activeMessage.locator('strong')).toHaveText('bold', { timeout: 30_000 }); + + const firstWord = activeMessage.locator('[data-sd-animate]').filter({ hasText: /^Streaming$/ }); + await expect(firstWord).toHaveCount(1); + await expect(firstWord).toHaveCSS('--sd-duration', '140ms'); + await expect(completedMessage.locator('[data-sd-animate]')).toHaveCount(0); + + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message_chunk', + messageId: 'streamdown-assistant', + content: { + type: 'text', + text: '** words with `inlineCode()`, https://example.com。后续, [docs](https://example.org), and ', + }, + }]); + await expect(activeMessage.locator('code').filter({ hasText: 'inlineCode()' })).toBeVisible(); + await expect(activeMessage.getByText('https://example.com', { exact: true })).toBeVisible(); + await expect(activeMessage).toContainText('。后续'); + const newWord = activeMessage.locator('[data-sd-animate]').filter({ hasText: /^words$/ }); + await expect(newWord).toHaveCount(1); + await expect(newWord).toHaveCSS('--sd-duration', '140ms'); + await expect(firstWord).toHaveCount(1); + await expect(firstWord).toHaveCSS('--sd-duration', '0ms'); + await expect(activeMessage.locator('a')).toHaveCount(0); + await expect(activeMessage.getByText('docs', { exact: true })).toBeVisible(); + await expect(activeMessage).toContainText(''); + await expect(activeMessage.locator('script')).toHaveCount(0); + + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message_chunk', + messageId: 'streamdown-assistant', + content: { type: 'text', text: '\n\n```javascript\nconst answer = 42;' }, + }]); + await expect(activeMessage).toContainText('Streaming bold words'); + const incompleteCodeBlock = activeMessage.locator('[data-streamdown="code-block"][data-incomplete="true"]'); + await expect(incompleteCodeBlock).toBeVisible(); + await expect(incompleteCodeBlock).toContainText('const answer = 42;'); + + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message_chunk', + messageId: 'streamdown-assistant', + content: { type: 'text', text: '\n```' }, + }]); + await expect(activeMessage.locator('[data-streamdown="code-block"][data-incomplete="true"]')).toHaveCount(0); + await expect(activeMessage.locator('pre span[style*="--sdm-c"]').first()).toBeVisible({ timeout: 30_000 }); + + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message_chunk', + messageId: 'streamdown-assistant', + content: { + type: 'text', + text: '\n\n## Spaced heading\n\n---\n\n- compact one\n- compact two\n\nFinal streamed text.', + }, + }]); + await expect(activeMessage).toContainText('Final streamed text.'); + await expect(activeMessage.locator('.clawx-streamdown')).not.toHaveClass(/space-y-0/); + + const headingMargins = await activeMessage.getByRole('heading', { name: 'Spaced heading' }).evaluate((element) => { + const style = window.getComputedStyle(element); + return [Number.parseFloat(style.marginTop), Number.parseFloat(style.marginBottom)]; + }); + expect(headingMargins[0]).toBeGreaterThan(0); + expect(headingMargins[1]).toBeGreaterThan(0); + const ruleMargins = await activeMessage.locator('hr').evaluate((element) => { + const style = window.getComputedStyle(element); + return [Number.parseFloat(style.marginTop), Number.parseFloat(style.marginBottom)]; + }); + expect(ruleMargins[0]).toBeGreaterThan(0); + expect(ruleMargins[1]).toBeGreaterThan(0); + + const compactItems = activeMessage.locator('[data-streamdown="list-item"]').filter({ hasText: /compact/ }); + const compactGap = await compactItems.evaluateAll((items) => { + const firstRect = items[0].getBoundingClientRect(); + const secondRect = items[1].getBoundingClientRect(); + return secondRect.top - firstRect.bottom; + }); + expect(compactGap).toBeLessThanOrEqual(2); + const compactPadding = await compactItems.first().evaluate((element) => { + const style = window.getComputedStyle(element); + return [Number.parseFloat(style.paddingTop), Number.parseFloat(style.paddingBottom)]; + }); + expect(compactPadding).toEqual([2, 2]); + + const codeBlock = activeMessage.locator('[data-streamdown="code-block"]'); + const codeHeader = codeBlock.locator('[data-streamdown="code-block-header"]'); + await expect(codeHeader).toHaveCSS('justify-content', 'flex-end'); + expect(await codeHeader.evaluate((element) => element.getBoundingClientRect().height)).toBe(28); + const copyCode = codeBlock.getByTitle('Copy code'); + await expect(copyCode).toBeDisabled(); + expect(await codeHeader.evaluate((header, button) => { + const headerRect = header.getBoundingClientRect(); + const buttonRect = (button as Element).getBoundingClientRect(); + return Math.abs( + (headerRect.top + headerRect.height / 2) - (buttonRect.top + buttonRect.height / 2), + ); + }, await copyCode.elementHandle())).toBeLessThanOrEqual(1); + + await resolveAcpPrompt(app); + await expect(page.getByTestId('chat-composer-send')).toHaveAttribute('title', 'Send'); + await expect(activeMessage.locator('[data-sd-animate]')).toHaveCount(0); + await expect(activeMessage).toContainText('Streaming bold words'); + await expect(activeMessage).toContainText('Final streamed text.'); + await expect(copyCode).toBeEnabled(); + await copyCode.click(); + await expect.poll(() => page.evaluate(async () => (await navigator.clipboard.readText()).trim())) + .toBe('const answer = 42;'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/chat-table-header-light.spec.ts b/tests/e2e/chat-table-header-light.spec.ts index 9cb8ccdf..98468710 100644 --- a/tests/e2e/chat-table-header-light.spec.ts +++ b/tests/e2e/chat-table-header-light.spec.ts @@ -84,7 +84,7 @@ async function emitAcpSessionUpdates(app: ElectronApplication, updates: AcpSessi const CLOUD_ARTIFACT_PATH = '/opt/cursor/artifacts/chat_table_header_light.png'; test.describe('ClawX chat table header styling', () => { - test('renders markdown table headers with transparent background and bold text in light theme', async ({ launchElectronApp }, testInfo) => { + test('renders transparent light and muted dark Markdown table headers', async ({ launchElectronApp }, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -156,16 +156,25 @@ test.describe('ClawX chat table header styling', () => { root.classList.add('light'); }); - const header = page.locator('.prose table thead th').first(); - await expect(header).toBeVisible({ timeout: 30_000 }); + const headerParent = page.locator('.prose table thead').first(); + const headerCell = headerParent.locator('th').first(); + await expect(headerCell).toBeVisible({ timeout: 30_000 }); - const headerStyles = await header.evaluate((el) => { - const style = window.getComputedStyle(el); - return { backgroundColor: style.backgroundColor, fontWeight: style.fontWeight }; + const lightHeaderStyles = await headerParent.evaluate((element) => { + const cell = element.querySelector('th'); + if (!cell) throw new Error('Markdown table header cell is missing'); + const parentStyle = window.getComputedStyle(element); + const cellStyle = window.getComputedStyle(cell); + return { + parentBackgroundColor: parentStyle.backgroundColor, + cellBackgroundColor: cellStyle.backgroundColor, + cellFontWeight: cellStyle.fontWeight, + }; }); - expect(headerStyles.backgroundColor).toBe('rgba(0, 0, 0, 0)'); - expect(Number(headerStyles.fontWeight)).toBeGreaterThanOrEqual(700); + expect(lightHeaderStyles.parentBackgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(lightHeaderStyles.cellBackgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(Number(lightHeaderStyles.cellFontWeight)).toBeGreaterThanOrEqual(700); const tableEl = page.locator('.prose table').first(); await tableEl.scrollIntoViewIfNeeded(); @@ -183,6 +192,29 @@ test.describe('ClawX chat table header styling', () => { } catch { // Cloud artifact directory is optional; ignore when unavailable (e.g. on CI runners). } + + await page.evaluate(() => { + const root = document.documentElement; + root.classList.remove('light'); + root.classList.add('dark'); + }); + const darkHeaderStyles = await headerParent.evaluate((element) => { + const cell = element.querySelector('th'); + if (!cell) throw new Error('Markdown table header cell is missing'); + const mutedProbe = document.createElement('div'); + mutedProbe.className = 'bg-muted'; + document.body.appendChild(mutedProbe); + const mutedBackgroundColor = window.getComputedStyle(mutedProbe).backgroundColor; + mutedProbe.remove(); + return { + parentBackgroundColor: window.getComputedStyle(element).backgroundColor, + cellBackgroundColor: window.getComputedStyle(cell).backgroundColor, + mutedBackgroundColor, + }; + }); + + expect(darkHeaderStyles.parentBackgroundColor).toBe(darkHeaderStyles.mutedBackgroundColor); + expect(darkHeaderStyles.cellBackgroundColor).toBe(darkHeaderStyles.mutedBackgroundColor); } finally { await closeElectronApp(app); } diff --git a/tests/e2e/chat-workspace-context.spec.ts b/tests/e2e/chat-workspace-context.spec.ts index 365a3393..b9cb9615 100644 --- a/tests/e2e/chat-workspace-context.spec.ts +++ b/tests/e2e/chat-workspace-context.spec.ts @@ -96,7 +96,6 @@ type WorkspaceMockOptions = { recentWorkspacePaths?: string[]; workspaceLabels?: Record; unavailableWorkspacePath?: string; - sessionHistory?: Array<{ role: string; content: unknown; timestamp?: number }>; sessionId?: string; sessionLabel?: string; sessionDerivedTitle?: string | null; @@ -106,7 +105,6 @@ type WorkspaceMockOptions = { async function installWorkspaceMocks(app: ElectronApplication, options: WorkspaceMockOptions = {}) { const nowMs = Date.now(); const gatewayStatus = { state: 'running', gatewayReady: true, port: 18789, pid: 12345, connectedAt: nowMs }; - const sessionHistory = options.sessionHistory ?? []; const recentWorkspacePaths = options.recentWorkspacePaths ?? [DEFAULT_WORKSPACE]; const inheritedRecentWorkspacePaths = [ SESSION_WORKSPACE, @@ -161,14 +159,6 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac sessions: [sessionRow], }, }, - [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: { - success: true, - result: { messages: sessionHistory }, - }, - [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: { - success: true, - result: { messages: sessionHistory }, - }, }, hostApi: { [stableStringify(['settings', 'getAll', null])]: settingsSnapshot, diff --git a/tests/e2e/cron-run-live-status.spec.ts b/tests/e2e/cron-run-live-status.spec.ts index 59fe6dcb..8bd5e2e6 100644 --- a/tests/e2e/cron-run-live-status.spec.ts +++ b/tests/e2e/cron-run-live-status.spec.ts @@ -133,11 +133,9 @@ test.describe('ClawX cron run live status', () => { await expect(cronSidebarButton).toBeVisible({ timeout: 30_000 }); await cronSidebarButton.click(); - // Transcript replay now arrives through ACP; the legacy execution graph stays absent. await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 }); await emitAcpSessionUpdates(app, CRON_BASE_KEY, [cronTriggerUpdate], true); await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); await emitAcpSessionUpdates(app, CRON_BASE_KEY, [{ sessionUpdate: 'tool_call', @@ -150,7 +148,6 @@ test.describe('ClawX cron run live status', () => { await expect(page.getByTestId('acp-tool-call-card')).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId('acp-tool-call-card')).toContainText('web_search'); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); await emitAcpSessionUpdates(app, CRON_BASE_KEY, [{ sessionUpdate: 'tool_call_update', @@ -293,7 +290,6 @@ test.describe('ClawX cron run live status', () => { await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 }); await emitAcpSessionUpdates(app, CRON_BASE_KEY, [cronTriggerUpdate], true); await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); // Simulate joining a run already in progress: the first ACP update the // renderer sees is a tool card, and it still renders live in the current session. @@ -308,7 +304,6 @@ test.describe('ClawX cron run live status', () => { await expect(page.getByTestId('acp-tool-call-card')).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId('acp-tool-call-card')).toContainText('read'); - await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); await emitAcpSessionUpdates(app, CRON_BASE_KEY, [{ sessionUpdate: 'tool_call_update', diff --git a/tests/e2e/dialog-transitions.spec.ts b/tests/e2e/dialog-transitions.spec.ts index 31383cb6..7c151bb3 100644 --- a/tests/e2e/dialog-transitions.spec.ts +++ b/tests/e2e/dialog-transitions.spec.ts @@ -159,12 +159,6 @@ test.describe('dialog transitions', () => { updatedAt: nowMs, }], }, - [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: { - messages: [], - }, - [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: { - messages: [], - }, }, hostApi: { [stableStringify(['/api/gateway/status', 'GET'])]: { @@ -214,6 +208,23 @@ test.describe('dialog transitions', () => { await expect(confirmDialog).toContainText('Preserved session'); + await installIpcMocks(app, { + hostApi: { + [stableStringify(['sessions', 'delete', { id: MAIN_SESSION_KEY }])]: { + success: false, + error: 'Session is locked', + }, + }, + }); + await page.getByTestId('confirm-dialog-confirm-button').click(); + await expect(confirmDialog).toBeVisible(); + await expect(sessionRow).toBeVisible(); + + await installIpcMocks(app, { + hostApi: { + [stableStringify(['sessions', 'delete', { id: MAIN_SESSION_KEY }])]: { success: true }, + }, + }); await page.getByTestId('confirm-dialog-confirm-button').click(); await expect(confirmDialog).toHaveAttribute('data-state', 'closed'); await expect(confirmDialog).toContainText('Preserved session'); diff --git a/tests/e2e/fixtures/electron.ts b/tests/e2e/fixtures/electron.ts index 9deeec8a..8d566218 100644 --- a/tests/e2e/fixtures/electron.ts +++ b/tests/e2e/fixtures/electron.ts @@ -342,6 +342,91 @@ export { closeElectronApp }; export { getStableWindow }; export { expect }; +export async function emitAcpSessionUpdates( + app: ElectronApplication, + input: { + sessionKey: string; + updates: Array & { sessionUpdate: string }>; + generation?: number; + historical?: boolean; + intervalMs?: number; + }, +): Promise { + await app.evaluate(async ({ app: _app }, event) => { + const { BrowserWindow } = process.mainModule!.require('electron') as typeof import('electron'); + for (const update of event.updates) { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('chat:acp-session-update', { + sessionKey: event.sessionKey, + generation: event.generation ?? 1, + ...(event.historical ? { historical: true } : {}), + notification: { sessionId: event.sessionKey, update }, + }); + } + if (event.intervalMs && event.intervalMs > 0) { + await new Promise((resolveWait) => setTimeout(resolveWait, event.intervalMs)); + } + } + }, input); +} + +export async function startMainCpuProfile(app: ElectronApplication): Promise { + await app.evaluate(async () => { + const inspector = process.mainModule!.require('node:inspector') as typeof import('node:inspector'); + const globals = globalThis as unknown as { __e2eMainCpuProfiler?: import('node:inspector').Session }; + if (globals.__e2eMainCpuProfiler) throw new Error('Main CPU profiler is already running'); + + const session = new inspector.Session(); + session.connect(); + const post = (method: string, params?: Record): Promise> => ( + new Promise((resolvePost, rejectPost) => { + session.post(method, params ?? {}, (error, result) => { + if (error) rejectPost(error); + else resolvePost((result ?? {}) as Record); + }); + }) + ); + + try { + await post('Profiler.enable'); + await post('Profiler.setSamplingInterval', { interval: 1_000 }); + await post('Profiler.start'); + globals.__e2eMainCpuProfiler = session; + } catch (error) { + session.disconnect(); + throw error; + } + }); +} + +export async function stopMainCpuProfile(app: ElectronApplication): Promise> { + return await app.evaluate(async () => { + const globals = globalThis as unknown as { __e2eMainCpuProfiler?: import('node:inspector').Session }; + const session = globals.__e2eMainCpuProfiler; + if (!session) throw new Error('Main CPU profiler is not running'); + delete globals.__e2eMainCpuProfiler; + + const post = (method: string, params?: Record): Promise> => ( + new Promise((resolvePost, rejectPost) => { + session.post(method, params ?? {}, (error, result) => { + if (error) rejectPost(error); + else resolvePost((result ?? {}) as Record); + }); + }) + ); + + try { + const result = await post('Profiler.stop'); + await post('Profiler.disable'); + const profile = result.profile; + if (!profile || typeof profile !== 'object') throw new Error('Main CPU profiler returned no profile'); + return profile as Record; + } finally { + session.disconnect(); + } + }); +} + export async function installIpcMocks( app: ElectronApplication, config: IpcMockConfig, @@ -658,7 +743,6 @@ export async function installAttachmentHostFixture( gatewayRpc: { [stableStringify(['sessions.list', {}])]: sessionsList, [stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: sessionsList, - [stableStringify(['chat.history', null])]: { success: true, result: { messages: [] } }, }, hostApi: { [stableStringify(['settings', 'getAll', null])]: { diff --git a/tests/e2e/fixtures/web-browser.ts b/tests/e2e/fixtures/web-browser.ts index b45dc23b..6d4490aa 100644 --- a/tests/e2e/fixtures/web-browser.ts +++ b/tests/e2e/fixtures/web-browser.ts @@ -350,7 +350,6 @@ export async function prepareWebBrowserApp(app: ElectronApplication, workspaceDi gatewayRpc: { [stableStringify(['sessions.list', {}])]: sessionsList, [stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: sessionsList, - [stableStringify(['chat.history', null])]: { success: true, result: { messages: [] } }, }, hostApi: { [stableStringify(['settings', 'getAll', null])]: { diff --git a/tests/e2e/gateway-lifecycle.spec.ts b/tests/e2e/gateway-lifecycle.spec.ts index 73049cd2..44fd594e 100644 --- a/tests/e2e/gateway-lifecycle.spec.ts +++ b/tests/e2e/gateway-lifecycle.spec.ts @@ -214,7 +214,7 @@ test.describe('ClawX gateway lifecycle resilience', () => { await expect(restartIndicator).toHaveAttribute('data-state', 'hidden'); }); - test('chat sidebar history reloads when gateway becomes ready after restart', async ({ electronApp, page }) => { + test('chat sidebar session catalog reloads when gateway becomes ready after restart', async ({ electronApp, page }) => { await installIpcMocks(electronApp, { gatewayStatus: { state: 'running', port: 18789, pid: 100, connectedAt: 1, gatewayReady: false }, hostApi: { diff --git a/tests/e2e/hardware-acceleration.spec.ts b/tests/e2e/hardware-acceleration.spec.ts new file mode 100644 index 00000000..fa67a036 --- /dev/null +++ b/tests/e2e/hardware-acceleration.spec.ts @@ -0,0 +1,50 @@ +import { closeElectronApp, expect, test } from './fixtures/electron'; + +test.describe('Electron hardware acceleration', () => { + test('does not request software rendering by default', async ({ electronApp }) => { + const disablesGpu = await electronApp.evaluate(async ({ app }) => ( + app.commandLine.hasSwitch('disable-gpu') + )); + + expect(disablesGpu).toBe(false); + }); + + test('keeps Chromium compositing hardware accelerated by default', async ({ electronApp }) => { + const status = await electronApp.evaluate(async ({ app }) => { + await app.getGPUInfo('basic'); + + return { + enabled: app.isHardwareAccelerationEnabled(), + gpuCompositing: app.getGPUFeatureStatus().gpu_compositing, + }; + }); + + test.skip( + process.platform !== 'darwin' || status.gpuCompositing !== 'enabled', + 'Requires a controlled desktop GPU environment', + ); + expect(status).toEqual({ + enabled: true, + gpuCompositing: 'enabled', + }); + }); + + test('retains Chromium native software-rendering fallback', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true, additionalArgs: ['--disable-gpu'] }); + try { + const status = await app.evaluate(async ({ app: electronApp }) => { + return { + disabledBySwitch: electronApp.commandLine.hasSwitch('disable-gpu'), + enabled: electronApp.isHardwareAccelerationEnabled(), + }; + }); + + expect(status).toEqual({ + disabledBySwitch: true, + enabled: false, + }); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/markdown-file-preview.spec.ts b/tests/e2e/markdown-file-preview.spec.ts new file mode 100644 index 00000000..6fddfc82 --- /dev/null +++ b/tests/e2e/markdown-file-preview.spec.ts @@ -0,0 +1,209 @@ +import type { ElectronApplication, Page } from '@playwright/test'; +import { + closeElectronApp, + expect, + getStableWindow, + installAttachmentHostFixture, + test, +} from './fixtures/electron'; + +const MAIN_SESSION_KEY = 'agent:main:main'; +const FILE_NAME = 'streamdown-preview.md'; +const MARKDOWN_FIXTURE = [ + '---', + 'title: Hidden preview metadata', + '---', + '', + '# Static Streamdown Preview', + '', + 'Visible Markdown body.', + '', + '## Heading 2', + '', + '### Heading 3', + '', + '#### Heading 4', + '', + '##### Heading 5', + '', + '###### Heading 6', + '', + '---', + '', + '- unordered one', + '- unordered two', + '', + '1. ordered one', + '2. ordered two', + '', + '- [x] task one', + '- [ ] task two', + '', + '| Name | Value |', + '| --- | --- |', + '| alpha | beta |', + '', + '$x^2$', + '', + 'https://example.com。后续', + '', + '```javascript', + 'const highlightedValue = 42;', + 'const secondLine = highlightedValue + 1;', + 'return secondLine;', + '```', + '', + '```mermaid', + 'graph TD', + ' A --> B', + '```', +].join('\n'); + +async function openChat(app: ElectronApplication): Promise { + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) throw error; + } + await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page.getByTestId('chat-page')).toBeVisible(); + return page; +} + +test.describe('Markdown file preview', () => { + test('renders workspace Markdown through static Streamdown', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + const fixture = await installAttachmentHostFixture(app, { + sessions: [{ key: MAIN_SESSION_KEY, title: 'Markdown preview session' }], + }); + await fixture.createWorkspaceFile(FILE_NAME, MARKDOWN_FIXTURE); + await fixture.setSessionReplay(MAIN_SESSION_KEY, []); + await fixture.setTranscriptResponses(MAIN_SESSION_KEY, [[]]); + + const page = await openChat(app); + await page.getByTestId('chat-toolbar-workspace').click(); + + const panel = page.getByTestId('artifact-panel'); + const workspaceTree = panel.getByTestId('workspace-tree'); + await expect(workspaceTree).toBeVisible(); + await workspaceTree.getByTitle(FILE_NAME, { exact: true }).click(); + + const preview = panel.locator('.clawx-markdown-preview'); + await expect(preview).toBeVisible(); + await expect(preview.getByRole('heading', { name: 'Static Streamdown Preview' })).toBeVisible(); + await expect(preview.getByText('Visible Markdown body.')).toBeVisible(); + await expect(preview).not.toContainText('Hidden preview metadata'); + await expect(preview.locator('.katex')).toBeVisible(); + + for (const level of [1, 2, 3, 4, 5, 6]) { + const heading = preview.locator(`h${level}`); + const margins = await heading.evaluate((element) => { + const style = window.getComputedStyle(element); + return [Number.parseFloat(style.marginTop), Number.parseFloat(style.marginBottom)]; + }); + if (level === 1) expect(margins[0], 'first h1 top margin').toBe(0); + else expect(margins[0], `h${level} top margin`).toBeGreaterThan(0); + expect(margins[1], `h${level} bottom margin`).toBeGreaterThan(0); + } + + const ruleMargins = await preview.locator('hr').evaluate((element) => { + const style = window.getComputedStyle(element); + return [Number.parseFloat(style.marginTop), Number.parseFloat(style.marginBottom)]; + }); + expect(ruleMargins[0]).toBeGreaterThan(0); + expect(ruleMargins[1]).toBeGreaterThan(0); + + for (const firstItemText of ['unordered one', 'ordered one', 'task one']) { + const firstItem = preview.locator('li').filter({ hasText: firstItemText }).first(); + const secondItem = firstItem.locator('xpath=following-sibling::li[1]'); + const itemGap = await firstItem.evaluate((element, nextElement) => { + const firstRect = element.getBoundingClientRect(); + const secondRect = (nextElement as Element).getBoundingClientRect(); + return secondRect.top - firstRect.bottom; + }, await secondItem.elementHandle()); + expect(itemGap, `${firstItemText} list gap`).toBeLessThanOrEqual(2); + const itemPadding = await firstItem.evaluate((element) => { + const style = window.getComputedStyle(element); + return [Number.parseFloat(style.paddingTop), Number.parseFloat(style.paddingBottom)]; + }); + expect(itemPadding, `${firstItemText} list padding`).toEqual([2, 2]); + const listMargins = await firstItem.locator('..').evaluate((element) => { + const style = window.getComputedStyle(element); + return [Number.parseFloat(style.marginTop), Number.parseFloat(style.marginBottom)]; + }); + expect(listMargins[0], `${firstItemText} list top margin`).toBeGreaterThan(0); + expect(listMargins[1], `${firstItemText} list bottom margin`).toBeGreaterThan(0); + expect(listMargins[0], `${firstItemText} list top margin`).toBeLessThanOrEqual(8); + expect(listMargins[1], `${firstItemText} list bottom margin`).toBeLessThanOrEqual(8); + } + + const tableBorderLayers = await preview.locator('table').evaluate((table, previewRoot) => { + const layers: string[] = []; + let element = table.parentElement; + while (element && element !== previewRoot) { + const style = window.getComputedStyle(element); + if (Number.parseFloat(style.borderTopWidth) > 0) { + layers.push(element.getAttribute('data-streamdown') ?? element.tagName.toLowerCase()); + } + element = element.parentElement; + } + return layers; + }, await preview.elementHandle()); + expect(tableBorderLayers).toEqual([]); + + const cjkLink = preview.getByText('https://example.com', { exact: true }); + await expect(cjkLink).toBeVisible(); + await expect(cjkLink).toHaveText('https://example.com'); + await expect(cjkLink).not.toHaveAttribute('href'); + expect(await cjkLink.evaluate((element) => element.nextSibling?.textContent)).toBe('。后续'); + + const javascriptBlock = preview.locator('[data-streamdown="code-block"][data-language="javascript"]'); + await expect(javascriptBlock).toContainText('const highlightedValue = 42;'); + await expect(javascriptBlock.locator('span[style*="--sdm-c"]').first()).toBeVisible(); + const codeHeader = javascriptBlock.locator('[data-streamdown="code-block-header"]'); + await expect(codeHeader).toHaveCSS('justify-content', 'flex-end'); + expect(await codeHeader.evaluate((element) => element.getBoundingClientRect().height)).toBe(28); + const copyButton = javascriptBlock.getByTitle('Copy code'); + await expect(copyButton).toBeVisible(); + expect(await codeHeader.evaluate((header, button) => { + const headerRect = header.getBoundingClientRect(); + const buttonRect = (button as Element).getBoundingClientRect(); + return Math.abs( + (headerRect.top + headerRect.height / 2) - (buttonRect.top + buttonRect.height / 2), + ); + }, await copyButton.elementHandle())).toBeLessThanOrEqual(1); + expect(await page.evaluate(() => ({ + clipboard: typeof navigator.clipboard?.writeText === 'function', + secureContext: window.isSecureContext, + }))).toEqual({ clipboard: true, secureContext: true }); + await copyButton.click(); + await expect.poll(() => page.evaluate(async () => ( + await navigator.clipboard.readText() + ).trim().replace(/\r\n?/g, '\n'))) + .toBe([ + 'const highlightedValue = 42;', + 'const secondLine = highlightedValue + 1;', + 'return secondLine;', + ].join('\n')); + const codeBody = javascriptBlock.locator('[data-streamdown="code-block-body"] pre'); + await expect(codeBody).toHaveCSS('white-space', 'pre-wrap'); + await expect(codeBody).toHaveCSS('overflow-x', 'auto'); + await expect(codeBody).toHaveCSS('overflow-wrap', 'break-word'); + const codeLines = codeBody.locator('code > span'); + await expect(codeLines).toHaveCount(3); + await expect(codeLines.first()).toHaveCSS('display', 'block'); + const lineTops = await codeLines.evaluateAll((lines) => lines.map((line) => line.getBoundingClientRect().top)); + expect(new Set(lineTops).size).toBe(3); + + const mermaidBlock = preview.locator('[data-streamdown="code-block"][data-language="mermaid"]'); + await expect(mermaidBlock).toContainText('graph TD'); + await expect(mermaidBlock.locator('[data-streamdown="code-block-body"] svg')).toHaveCount(0); + await expect(preview.locator('[data-streamdown="mermaid"]')).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/office-document-preview.spec.ts b/tests/e2e/office-document-preview.spec.ts index 51285c0e..4b8adbde 100644 --- a/tests/e2e/office-document-preview.spec.ts +++ b/tests/e2e/office-document-preview.spec.ts @@ -225,14 +225,13 @@ test.describe('real Office document previews', () => { await assertSinglePptxViewer(page); await expect(previous).toBeDisabled(); await expect(next).toBeEnabled(); - const deckAFirst = await waitForStableCanvas(canvas); + await waitForStableCanvas(canvas); await next.click(); await expect(panel.getByText('2 / 2', { exact: true })).toBeVisible(); await expect(previous).toBeEnabled(); await expect(next).toBeDisabled(); - const deckASecond = await waitForStableCanvas(canvas); - expect(deckASecond.digest).not.toBe(deckAFirst.digest); + await waitForStableCanvas(canvas); await expect.poll(async () => page.evaluate(() => ( (window as typeof window & { __officePptxChartCompletions?: number }) .__officePptxChartCompletions ?? 0 @@ -240,10 +239,10 @@ test.describe('real Office document previews', () => { await previous.click(); await expect(panel.getByText('1 / 2', { exact: true })).toBeVisible(); - expect((await waitForStableCanvas(canvas)).digest).toBe(deckAFirst.digest); + await waitForStableCanvas(canvas); await next.click(); await expect(panel.getByText('2 / 2', { exact: true })).toBeVisible(); - expect((await waitForStableCanvas(canvas)).digest).toBe(deckASecond.digest); + await waitForStableCanvas(canvas); const deckBActivity = page.getByTestId('acp-file-button').filter({ hasText: 'slides-b.pptx' }); await expect(deckBActivity).toHaveAccessibleName('Created slides-b.pptx'); @@ -251,31 +250,29 @@ test.describe('real Office document previews', () => { await expect(panel.getByTestId('artifact-panel-tab-preview')).toHaveAttribute('class', /bg-foreground\/10/); await expect(panel.getByText('1 / 2', { exact: true })).toBeVisible({ timeout: 30_000 }); await assertSinglePptxViewer(page); - const deckBFirst = await waitForStableCanvas(canvas); - expect(deckBFirst.digest).not.toBe(deckAFirst.digest); - expect(deckBFirst.digest).not.toBe(deckASecond.digest); + await waitForStableCanvas(canvas); await panel.getByTestId('artifact-panel-tab-browser').click(); await expect(panel.getByText('2 / 2', { exact: true })).toBeVisible({ timeout: 30_000 }); await assertSinglePptxViewer(page); - expect((await waitForStableCanvas(canvas)).digest).toBe(deckASecond.digest); + await waitForStableCanvas(canvas); await panel.getByTestId('artifact-panel-tab-preview').click(); await expect(panel.getByText('1 / 2', { exact: true })).toBeVisible({ timeout: 30_000 }); await assertSinglePptxViewer(page); - expect((await waitForStableCanvas(canvas)).digest).toBe(deckBFirst.digest); + await waitForStableCanvas(canvas); await next.click(); await expect(panel.getByText('2 / 2', { exact: true })).toBeVisible(); - const deckBSecond = await waitForStableCanvas(canvas); - expect(deckBSecond.digest).not.toBe(deckBFirst.digest); + await waitForStableCanvas(canvas); await panel.getByTestId('artifact-panel-tab-browser').click(); await expect(panel.getByText('2 / 2', { exact: true })).toBeVisible({ timeout: 30_000 }); await assertSinglePptxViewer(page); + await waitForStableCanvas(canvas); await panel.getByTestId('artifact-panel-tab-preview').click(); await expect(panel.getByText('2 / 2', { exact: true })).toBeVisible({ timeout: 30_000 }); await assertSinglePptxViewer(page); - expect((await waitForStableCanvas(canvas)).digest).toBe(deckBSecond.digest); + await waitForStableCanvas(canvas); const beforeResize = await canvasFingerprint(canvas); const beforeResizeBacking = { width: beforeResize.width, height: beforeResize.height }; diff --git a/tests/e2e/renderer-performance.spec.ts b/tests/e2e/renderer-performance.spec.ts new file mode 100644 index 00000000..39cec73c --- /dev/null +++ b/tests/e2e/renderer-performance.spec.ts @@ -0,0 +1,438 @@ +import { writeFile } from 'node:fs/promises'; +import type { ElectronApplication, Page, TestInfo } from '@playwright/test'; +import { + closeElectronApp, + emitAcpSessionUpdates, + expect, + getStableWindow, + installIpcMocks, + startMainCpuProfile, + stopMainCpuProfile, + test, +} from './fixtures/electron'; + +const SESSION_KEY = 'agent:main:performance'; +const WORKSPACE = '/synthetic-workspace'; +const HISTORY_TURNS = 80; +const STREAM_CHUNKS = 300; +const STREAM_INTERVAL_MS = 2; +const STREAM_SENTINEL = 'STREAM-PROFILE-COMPLETE'; +const INTERACTION_SECTIONS = 32; +const INTERACTION_SCROLL_STEPS = 32; + +type PerformanceMetric = { name: string; value: number }; + +type FramePacing = { + count: number; + p50Ms: number; + p95Ms: number; + maxMs: number; + over20Ms: number; + over34Ms: number; +}; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +function metricMap(metrics: PerformanceMetric[]): Record { + return Object.fromEntries(metrics.map(({ name, value }) => [name, value])); +} + +function metricDelta( + before: Record, + after: Record, + name: string, +): number | null { + const start = before[name]; + const end = after[name]; + return typeof start === 'number' && typeof end === 'number' ? end - start : null; +} + +async function openSyntheticChat(app: ElectronApplication): Promise { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 }, + gatewayRpc: { + [stableStringify(['sessions.list', {}])]: { + success: true, + result: { + sessions: [{ key: SESSION_KEY, displayName: 'Performance fixture', workspacePath: WORKSPACE }], + }, + }, + }, + hostApi: { + [stableStringify(['chat', 'loadAcpSession', { + sessionKey: SESSION_KEY, + workspaceRoot: WORKSPACE, + cwd: WORKSPACE, + }])]: { success: true, generation: 1 }, + [stableStringify(['chat', 'loadAcpSession', { + sessionKey: SESSION_KEY, + workspaceRoot: WORKSPACE, + cwd: WORKSPACE, + createIfMissing: true, + }])]: { success: true, generation: 1 }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { + success: true, + agents: [{ id: 'main', name: 'main', workspace: WORKSPACE, mainSessionKey: SESSION_KEY }], + }, + }, + }, + }, + }); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) throw error; + } + await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 }); + return page; +} + +async function waitForPaint(page: Page): Promise { + await page.evaluate(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); +} + +async function startFrameSampling(page: Page, durationMs: number): Promise { + await page.evaluate((duration) => { + const perfWindow = window as typeof window & { __clawxPerfFrameSamples?: number[] }; + const samples: number[] = []; + let previous = performance.now(); + const deadline = previous + duration; + const sample = (now: number) => { + samples.push(now - previous); + previous = now; + if (now < deadline) requestAnimationFrame(sample); + }; + perfWindow.__clawxPerfFrameSamples = samples; + requestAnimationFrame(sample); + }, durationMs); +} + +async function readFramePacing(page: Page): Promise { + const samples = await page.evaluate(() => ( + (window as typeof window & { __clawxPerfFrameSamples?: number[] }).__clawxPerfFrameSamples ?? [] + )); + const sorted = [...samples].sort((left, right) => left - right); + return { + count: samples.length, + p50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0, + p95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0, + maxMs: Math.max(0, ...samples), + over20Ms: samples.filter((duration) => duration > 20).length, + over34Ms: samples.filter((duration) => duration > 34).length, + }; +} + +function richStaticMarkdown(): string { + return Array.from({ length: INTERACTION_SECTIONS }, (_, index) => ` +## Rich Markdown section ${index + 1} + +This paragraph contains **bold text**, *emphasis*, ~~strikethrough~~, a [safe link](https://example.com), and CJK punctuation:中文、日本語、한국어。 + +- Nested item ${index + 1}.1 +- Nested item ${index + 1}.2 with \`inline code\` + +| Column A | Column B | Column C | +| --- | ---: | :---: | +| row ${index + 1} | value ${index * 17} | $x_${index + 1}^2$ | + +\`\`\`javascript +function section${index + 1}(value) { + return value * ${index + 1}; +} +\`\`\` +`).join('\n'); +} + +async function writeArtifact(testInfo: TestInfo, name: string, body: unknown): Promise { + const path = testInfo.outputPath(name); + await writeFile(path, JSON.stringify(body)); + await testInfo.attach(name, { path, contentType: 'application/json' }); + return path; +} + +test.use({ trace: 'off', video: 'off' }); + +test('profiles a populated timeline during a growing Markdown stream', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + const page = await openSyntheticChat(app); + const historicalUpdates = Array.from({ length: HISTORY_TURNS * 2 }, (_, index) => ({ + sessionUpdate: index % 2 === 0 ? 'user_message' : 'agent_message', + messageId: `profile-history-${index}`, + content: [{ + type: 'text', + text: index % 2 === 0 + ? `Synthetic question ${Math.floor(index / 2) + 1}` + : `Synthetic answer ${Math.floor(index / 2) + 1} with **Markdown** and \`inline code\`.`, + }], + })); + await emitAcpSessionUpdates(app, { + sessionKey: SESSION_KEY, + updates: historicalUpdates, + generation: 1, + historical: true, + }); + await expect(page.getByText(`Synthetic answer ${HISTORY_TURNS} with`)).toBeVisible({ timeout: 30_000 }); + await waitForPaint(page); + + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + const beforeMetrics = metricMap((await cdp.send('Performance.getMetrics')).metrics as PerformanceMetric[]); + await page.evaluate(() => { + const longTasks: number[] = []; + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) longTasks.push(entry.duration); + }); + try { + observer.observe({ type: 'longtask', buffered: false }); + } catch { + // Some Electron builds do not expose the Long Tasks API. + } + Object.assign(window, { __clawxPerfLongTasks: longTasks, __clawxPerfObserver: observer }); + }); + + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { interval: 1_000 }); + await startMainCpuProfile(app); + await cdp.send('Profiler.start'); + const startedAt = Date.now(); + + const liveUpdates = [ + { + sessionUpdate: 'user_message', + messageId: 'profile-live-user', + content: [{ type: 'text', text: 'Generate the synthetic streaming profile.' }], + }, + ...Array.from({ length: STREAM_CHUNKS }, (_, index) => ({ + sessionUpdate: 'agent_message_chunk', + messageId: 'profile-live-assistant', + content: { + type: 'text', + text: index === STREAM_CHUNKS - 1 + ? `\n\n${STREAM_SENTINEL}` + : `Chunk ${index + 1}: **bold** value ${index % 17}\n\n`, + }, + })), + ]; + await emitAcpSessionUpdates(app, { + sessionKey: SESSION_KEY, + updates: liveUpdates, + generation: 1, + intervalMs: STREAM_INTERVAL_MS, + }); + await expect(page.getByTestId('acp-assistant-message').filter({ hasText: STREAM_SENTINEL })).toBeVisible({ + timeout: 30_000, + }); + await waitForPaint(page); + + const elapsedMs = Date.now() - startedAt; + const rendererProfile = (await cdp.send('Profiler.stop')).profile; + const mainProfile = await stopMainCpuProfile(app); + const afterMetrics = metricMap((await cdp.send('Performance.getMetrics')).metrics as PerformanceMetric[]); + const longTasks = await page.evaluate(() => { + const perfWindow = window as typeof window & { + __clawxPerfLongTasks?: number[]; + __clawxPerfObserver?: PerformanceObserver; + }; + perfWindow.__clawxPerfObserver?.disconnect(); + return perfWindow.__clawxPerfLongTasks ?? []; + }); + await cdp.detach(); + + const benchmark = { + schemaVersion: 1, + scope: { + renderer: 'production-renderer-store-and-render-path', + main: 'synthetic-main-to-renderer-ipc-fanout', + }, + workload: { + historyTurns: HISTORY_TURNS, + streamChunks: STREAM_CHUNKS, + streamIntervalMs: STREAM_INTERVAL_MS, + }, + runtime: { + platform: process.platform, + arch: process.arch, + electron: await app.evaluate(async () => process.versions.electron), + chrome: await app.evaluate(async () => process.versions.chrome), + }, + elapsedMs, + renderer: { + taskDurationMs: metricDelta(beforeMetrics, afterMetrics, 'TaskDuration')! * 1_000, + scriptDurationMs: metricDelta(beforeMetrics, afterMetrics, 'ScriptDuration')! * 1_000, + layoutDurationMs: metricDelta(beforeMetrics, afterMetrics, 'LayoutDuration')! * 1_000, + recalcStyleDurationMs: metricDelta(beforeMetrics, afterMetrics, 'RecalcStyleDuration')! * 1_000, + jsHeapUsedSizeDelta: metricDelta(beforeMetrics, afterMetrics, 'JSHeapUsedSize'), + nodesDelta: metricDelta(beforeMetrics, afterMetrics, 'Nodes'), + longTasks: { + count: longTasks.length, + totalDurationMs: longTasks.reduce((total, duration) => total + duration, 0), + maxDurationMs: Math.max(0, ...longTasks), + }, + }, + }; + + await Promise.all([ + writeArtifact(testInfo, 'renderer-benchmark.json', benchmark), + writeArtifact(testInfo, 'renderer.cpuprofile', rendererProfile), + writeArtifact(testInfo, 'main.cpuprofile', mainProfile), + ]); + console.log(`ClawX chat performance: ${JSON.stringify(benchmark)}`); + + expect(liveUpdates).toHaveLength(STREAM_CHUNKS + 1); + const streamedMessage = page.getByTestId('acp-assistant-message').filter({ hasText: STREAM_SENTINEL }); + await expect(streamedMessage).toHaveCount(1); + const renderedText = await streamedMessage.textContent() ?? ''; + let previousChunkOffset = -1; + for (let index = 1; index <= STREAM_CHUNKS - 1; index += 1) { + const offset = renderedText.indexOf(`Chunk ${index}:`); + expect(offset, `stream chunk ${index} should be present and ordered`).toBeGreaterThan(previousChunkOffset); + previousChunkOffset = offset; + } + } finally { + await closeElectronApp(app); + } +}); + +test('profiles sidebar animation and scrolling with rich static Markdown', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + const page = await openSyntheticChat(app); + const markdown = richStaticMarkdown(); + await emitAcpSessionUpdates(app, { + sessionKey: SESSION_KEY, + generation: 1, + historical: true, + updates: [ + { + sessionUpdate: 'user_message', + messageId: 'interaction-user', + content: [{ type: 'text', text: 'Render a comprehensive Markdown fixture.' }], + }, + { + sessionUpdate: 'agent_message', + messageId: 'interaction-assistant', + content: [{ type: 'text', text: markdown }], + }, + ], + }); + const terminalSection = page.getByRole('heading', { name: `Rich Markdown section ${INTERACTION_SECTIONS}` }); + await expect(terminalSection).toBeAttached({ timeout: 30_000 }); + await expect(page.locator('.clawx-streamdown [data-streamdown="code-block-body"] pre').first()).toBeAttached({ timeout: 30_000 }); + await waitForPaint(page); + + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { interval: 1_000 }); + await startMainCpuProfile(app); + await cdp.send('Profiler.start'); + const beforeMetrics = metricMap((await cdp.send('Performance.getMetrics')).metrics as PerformanceMetric[]); + + const sidebar = page.getByTestId('sidebar'); + await startFrameSampling(page, 500); + await page.getByTestId('sidebar-collapse-toggle').click(); + await expect.poll(async () => (await sidebar.boundingBox())?.width ?? 0).toBe(68); + await page.waitForTimeout(550); + const sidebarCollapseFrames = await readFramePacing(page); + + await startFrameSampling(page, 500); + await page.getByTestId('sidebar-collapse-toggle').click(); + await expect.poll(async () => (await sidebar.boundingBox())?.width ?? 0).toBeGreaterThan(200); + await page.waitForTimeout(550); + const sidebarExpandFrames = await readFramePacing(page); + const scrollContainer = page.getByTestId('chat-scroll-container'); + const scrollStart = await scrollContainer.evaluate((element) => { + element.scrollTop = 0; + return { + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + }; + }); + expect(scrollStart.scrollHeight).toBeGreaterThan(scrollStart.clientHeight); + const scrollBox = await scrollContainer.boundingBox(); + if (!scrollBox) throw new Error('Rich Markdown scroll container has no layout box'); + + await startFrameSampling(page, 1_600); + await page.mouse.move(scrollBox.x + scrollBox.width / 2, scrollBox.y + scrollBox.height / 2); + for (let index = 0; index < INTERACTION_SCROLL_STEPS; index += 1) { + await page.mouse.wheel(0, 180); + await page.waitForTimeout(30); + } + await page.waitForTimeout(650); + const scrollFrames = await readFramePacing(page); + const scrollEnd = await scrollContainer.evaluate((element) => element.scrollTop); + + const rendererProfile = (await cdp.send('Profiler.stop')).profile; + const mainProfile = await stopMainCpuProfile(app); + const afterMetrics = metricMap((await cdp.send('Performance.getMetrics')).metrics as PerformanceMetric[]); + const gpu = await app.evaluate(async ({ app: electronApp }) => ({ + hardwareAccelerationEnabled: electronApp.isHardwareAccelerationEnabled(), + gpuCompositing: electronApp.getGPUFeatureStatus().gpu_compositing, + rasterization: electronApp.getGPUFeatureStatus().rasterization, + })); + const dom = await page.evaluate(() => ({ + nodes: document.getElementsByTagName('*').length, + markdownNodes: document.querySelectorAll('.clawx-streamdown *').length, + })); + await cdp.detach(); + + const benchmark = { + schemaVersion: 1, + workload: { + markdownCharacters: markdown.length, + markdownSections: INTERACTION_SECTIONS, + scrollSteps: INTERACTION_SCROLL_STEPS, + }, + gpu, + dom, + sidebarCollapseFrames, + sidebarExpandFrames, + scrollFrames, + scrollDistance: scrollEnd - scrollStart.scrollTop, + renderer: { + taskDurationMs: metricDelta(beforeMetrics, afterMetrics, 'TaskDuration')! * 1_000, + scriptDurationMs: metricDelta(beforeMetrics, afterMetrics, 'ScriptDuration')! * 1_000, + layoutDurationMs: metricDelta(beforeMetrics, afterMetrics, 'LayoutDuration')! * 1_000, + recalcStyleDurationMs: metricDelta(beforeMetrics, afterMetrics, 'RecalcStyleDuration')! * 1_000, + }, + }; + + await Promise.all([ + writeArtifact(testInfo, 'renderer-interaction-benchmark.json', benchmark), + writeArtifact(testInfo, 'renderer-interaction.cpuprofile', rendererProfile), + writeArtifact(testInfo, 'main-interaction.cpuprofile', mainProfile), + ]); + console.log(`ClawX interaction performance: ${JSON.stringify(benchmark)}`); + + expect(markdown.length).toBeGreaterThan(10_000); + expect(sidebarCollapseFrames.count).toBeGreaterThan(0); + expect(sidebarExpandFrames.count).toBeGreaterThan(0); + expect(scrollFrames.count).toBeGreaterThan(0); + expect(scrollEnd - scrollStart.scrollTop).toBeGreaterThan(1_000); + } finally { + await closeElectronApp(app); + } +}); diff --git a/tests/unit/acp-chat-components.test.tsx b/tests/unit/acp-chat-components.test.tsx index 80f9853e..690bfd75 100644 --- a/tests/unit/acp-chat-components.test.tsx +++ b/tests/unit/acp-chat-components.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AcpToolCallCard } from '@/pages/Chat/AcpToolCallCard'; import { AcpAttachmentPart } from '@/pages/Chat/AcpAttachmentPart'; -import { AcpTimeline } from '@/pages/Chat/AcpTimeline'; +import { AcpTimeline, streamingMessageSegmentIds } from '@/pages/Chat/AcpTimeline'; import { AcpTurnFileActivity } from '@/pages/Chat/AcpTurnFileActivity'; import type { AcpTimelineSnapshot, AttachmentRenderPart, ToolCallItem } from '@/lib/acp/timeline-types'; import type { AcpFileActivityProjection } from '@/lib/acp/openclaw-file-activities'; @@ -245,6 +245,281 @@ describe('ACP chat timeline components', () => { expect(userMessage.querySelector('li')).not.toBeInTheDocument(); }); + it('repairs only the active final assistant Markdown part and scopes animation markers to it', async () => { + const state = snapshot({ + itemOrder: ['msg-a:0', 'thought:msg-a', 'msg-a:1'], + itemsById: { + 'msg-a:0': { + kind: 'message-segment', id: 'msg-a:0', role: 'assistant', messageId: 'msg-a', segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'Earlier **literal assistant' }], + }, + 'thought:msg-a': { + kind: 'thought', id: 'thought:msg-a', messageId: 'msg-a', + parts: [{ kind: 'markdown', text: 'Private *literal thought' }], + }, + 'msg-a:1': { + kind: 'message-segment', id: 'msg-a:1', role: 'assistant', messageId: 'msg-a', segmentIndex: 1, + parts: [ + { kind: 'markdown', text: 'Completed part.' }, + { kind: 'markdown', text: 'Stable paragraph.\n\n**streamed bold' }, + ], + }, + }, + openMessageSegments: { 'msg-a': 'msg-a:1' }, + segmentCounts: { 'msg-a': 2 }, + }); + + const { container, rerender } = render(); + const earlierSegment = container.querySelector('[data-acp-item-id="msg-a:0"]'); + const activeSegment = container.querySelector('[data-acp-item-id="msg-a:1"]'); + const thought = screen.getByTestId('acp-thought-block'); + const activeStreamdownRoot = activeSegment?.querySelectorAll('.clawx-streamdown')[1]; + const activeParagraph = activeStreamdownRoot?.querySelector('p'); + + expect(activeSegment?.querySelector('strong')).toHaveTextContent('streamed bold'); + expect(activeParagraph).toHaveTextContent('Stable paragraph.'); + expect(activeSegment?.querySelectorAll('[data-sd-animate]').length).toBeGreaterThan(0); + expect(earlierSegment).toHaveTextContent('Earlier **literal assistant'); + expect(earlierSegment?.querySelector('strong')).not.toBeInTheDocument(); + expect(earlierSegment?.querySelector('[data-sd-animate]')).not.toBeInTheDocument(); + expect(thought).toHaveTextContent('Private *literal thought'); + expect(thought.querySelector('em')).not.toBeInTheDocument(); + expect(thought.querySelector('[data-sd-animate]')).not.toBeInTheDocument(); + expect(activeStreamdownRoot).not.toHaveClass('space-y-0'); + + rerender(); + const settledStreamdownRoot = activeSegment?.querySelectorAll('.clawx-streamdown')[1]; + expect(settledStreamdownRoot).toBe(activeStreamdownRoot); + expect(settledStreamdownRoot?.querySelector('p')).toBe(activeParagraph); + expect(activeParagraph).toHaveTextContent('Stable paragraph.'); + expect(activeSegment).toHaveTextContent('**streamed bold'); + expect(activeSegment?.querySelector('strong')).not.toBeInTheDocument(); + await waitFor(() => { + expect(container.querySelector('[data-sd-animate]')).not.toBeInTheDocument(); + }); + }); + + it('renders completed GFM and all supported math delimiters', () => { + const state = snapshot({ + itemOrder: ['msg-a:0'], + itemsById: { + 'msg-a:0': { + kind: 'message-segment', id: 'msg-a:0', role: 'assistant', messageId: 'msg-a', segmentIndex: 0, + parts: [{ + kind: 'markdown', + text: [ + '| Item | Done |', + '| --- | --- |', + '| Tests | yes |', + '', + '- [x] migrated', + '', + '$x$ and \\(y\\)', + '', + '$$', + 'z', + '$$', + '', + '\\[w\\]', + ].join('\n'), + }], + }, + }, + }); + + const { container } = render(); + + expect(container.querySelector('table')).toBeInTheDocument(); + expect(container.querySelector('input[type="checkbox"]')).toBeChecked(); + expect(container.querySelectorAll('.katex')).toHaveLength(4); + expect(container.querySelectorAll('.katex-display')).toHaveLength(2); + }); + + it('keeps raw HTML literal, links inert, CJK punctuation outside autolinks, and images safe', () => { + const source = [ + '', + '', + '[label](https://example.com/path)', + '', + 'https://example.com。后续', + '', + '![safe](https://example.com/safe.png)', + '![unsafe](javascript:alert(1))', + ].join('\n'); + const state = snapshot({ + itemOrder: ['msg-a:0'], + itemsById: { + 'msg-a:0': { + kind: 'message-segment', id: 'msg-a:0', role: 'assistant', messageId: 'msg-a', segmentIndex: 0, + parts: [{ kind: 'markdown', text: source }], + }, + }, + }); + + const { container } = render(); + + expect(container).toHaveTextContent(''); + expect(container.querySelector('script')).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.getByText('https://example.com', { selector: 'span' })).toBeInTheDocument(); + expect(container).toHaveTextContent('。后续'); + expect(container.querySelectorAll('img')).toHaveLength(1); + expect(container.querySelector('img')).toHaveAttribute('src', 'https://example.com/safe.png'); + }); + + it('highlights JavaScript and keeps Mermaid fences as code', async () => { + const state = snapshot({ + itemOrder: ['msg-a:0'], + itemsById: { + 'msg-a:0': { + kind: 'message-segment', id: 'msg-a:0', role: 'assistant', messageId: 'msg-a', segmentIndex: 0, + parts: [{ + kind: 'markdown', + text: [ + '```javascript', + 'const answer = 42;', + '```', + '', + '```mermaid', + 'graph TD; A-->B;', + '```', + ].join('\n'), + }], + }, + }, + }); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector('pre span[style*="--sdm-c"]')).toBeInTheDocument(); + }); + const codeBlocks = container.querySelectorAll('[data-streamdown="code-block"]'); + expect(codeBlocks).toHaveLength(2); + expect(container.querySelector('[data-streamdown="mermaid-block"]')).not.toBeInTheDocument(); + expect(codeBlocks[1]?.querySelector('[data-streamdown="code-block-body"] svg')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('graph TD; A-->B;'); + }); + + it('keeps tool Markdown output literal and outside Streamdown', () => { + const state = snapshot({ + itemOrder: ['tool:read-file'], + itemsById: { + 'tool:read-file': toolCallItem({ + outputParts: [{ kind: 'markdown', text: '**literal tool output**' }], + }), + }, + }); + + render(); + + const output = screen.getByTestId('acp-tool-output-pre'); + expect(output).toHaveTextContent('**literal tool output**'); + expect(output.querySelector('strong')).not.toBeInTheDocument(); + expect(output.querySelector('[data-streamdown]')).not.toBeInTheDocument(); + }); + + it('derives only the open assistant segment while the renderer is streaming', () => { + const state = snapshot({ + itemOrder: ['msg-u:0', 'msg-a:0', 'tool:read-file', 'msg-a:1'], + itemsById: { + 'msg-u:0': { + kind: 'message-segment', id: 'msg-u:0', role: 'user', messageId: 'msg-u', segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'Read the file' }], + }, + 'msg-a:0': { + kind: 'message-segment', id: 'msg-a:0', role: 'assistant', messageId: 'msg-a', segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'I will inspect it.' }], + }, + 'tool:read-file': toolCallItem({}), + 'msg-a:1': { + kind: 'message-segment', id: 'msg-a:1', role: 'assistant', messageId: 'msg-a', segmentIndex: 1, + parts: [{ kind: 'markdown', text: 'The file contains' }], + }, + }, + openMessageSegments: { 'msg-a': 'msg-a:1' }, + segmentCounts: { 'msg-u': 1, 'msg-a': 2 }, + }); + + expect([...streamingMessageSegmentIds(state, true)]).toEqual(['msg-a:1']); + expect(streamingMessageSegmentIds(state, true)).not.toContain('msg-u:0'); + expect(streamingMessageSegmentIds(state, true)).not.toContain('msg-a:0'); + expect(streamingMessageSegmentIds(state, false).size).toBe(0); + }); + + it('does not reactivate a stale open assistant when an optimistic user segment is terminal', () => { + const state = snapshot({ + itemOrder: ['msg-a:0', 'msg-u:0'], + itemsById: { + 'msg-a:0': { + kind: 'message-segment', id: 'msg-a:0', role: 'assistant', messageId: 'msg-a', segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'Previous answer.' }], + }, + 'msg-u:0': { + kind: 'message-segment', id: 'msg-u:0', role: 'user', messageId: 'msg-u', segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'Follow up' }], optimistic: true, + }, + }, + openMessageSegments: { 'msg-a': 'msg-a:0', 'msg-u': 'msg-u:0' }, + segmentCounts: { 'msg-a': 1, 'msg-u': 1 }, + }); + + expect(streamingMessageSegmentIds(state, true).size).toBe(0); + }); + + it('returns only the current open assistant appended after the optimistic user segment', () => { + const state = snapshot({ + itemOrder: ['msg-a:0', 'msg-u:0', 'msg-b:0'], + itemsById: { + 'msg-a:0': { + kind: 'message-segment', id: 'msg-a:0', role: 'assistant', messageId: 'msg-a', segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'Previous answer.' }], + }, + 'msg-u:0': { + kind: 'message-segment', id: 'msg-u:0', role: 'user', messageId: 'msg-u', segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'Follow up' }], optimistic: true, + }, + 'msg-b:0': { + kind: 'message-segment', id: 'msg-b:0', role: 'assistant', messageId: 'msg-b', segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'Current answer' }], + }, + }, + openMessageSegments: { + 'msg-a': 'msg-a:0', + 'msg-u': 'msg-u:0', + 'msg-b': 'msg-b:0', + }, + segmentCounts: { 'msg-a': 1, 'msg-u': 1, 'msg-b': 1 }, + }); + + expect([...streamingMessageSegmentIds(state, true)]).toEqual(['msg-b:0']); + }); + + it('excludes an open assistant whose original final part is an attachment', () => { + const state = snapshot({ + itemOrder: ['msg-a:0'], + itemsById: { + 'msg-a:0': { + kind: 'message-segment', id: 'msg-a:0', role: 'assistant', messageId: 'msg-a', segmentIndex: 0, + parts: [ + { kind: 'markdown', text: 'Attached result.' }, + { + kind: 'attachment', + attachmentId: 'attachment:msg-a:0:1', + reference: { uri: 'file:///workspace/result.txt', name: 'result.txt' }, + source: 'acp-resource', + access: { status: 'pending' }, + }, + ], + }, + }, + openMessageSegments: { 'msg-a': 'msg-a:0' }, + segmentCounts: { 'msg-a': 1 }, + }); + + expect(streamingMessageSegmentIds(state, true).size).toBe(0); + }); + it('shows a fixed whole-turn duration on the ACP-replayed assistant turn', () => { const state = snapshot({ itemOrder: ['user-a:0', 'assistant-a:0'], diff --git a/tests/unit/acp-chat-store.test.ts b/tests/unit/acp-chat-store.test.ts index 7405d649..86b6541b 100644 --- a/tests/unit/acp-chat-store.test.ts +++ b/tests/unit/acp-chat-store.test.ts @@ -330,6 +330,50 @@ describe('ACP Chat store', () => { }); }); + it('commits each live streamed update immediately', async () => { + vi.stubEnv('MODE', 'production'); + vi.useFakeTimers(); + let unsubscribe = () => {}; + try { + const { ensureAcpChatSubscriptions, useAcpChatSessionStore } = await importStore(); + ensureAcpChatSubscriptions(); + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + }); + const onStoreChange = vi.fn(); + unsubscribe = useAcpChatSessionStore.subscribe(onStoreChange); + const liveUpdate = (text: string) => ({ + sessionKey: 'agent:pi:s1', + generation: 1, + notification: { + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'streamed-message', + content: { type: 'text', text }, + }, + }, + }); + + hostEventsMock.updateListener?.(liveUpdate('one')); + expect(onStoreChange).toHaveBeenCalledTimes(1); + expect(useAcpChatSessionStore.getState().timeline.itemsById['streamed-message:0']).toMatchObject({ + parts: [{ kind: 'markdown', text: 'one' }], + }); + + hostEventsMock.updateListener?.(liveUpdate(' two')); + expect(onStoreChange).toHaveBeenCalledTimes(2); + expect(useAcpChatSessionStore.getState().timeline.itemsById['streamed-message:0']).toMatchObject({ + parts: [{ kind: 'markdown', text: 'one two' }], + }); + } finally { + unsubscribe(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + } + }); + it('keeps the complete replay after preparing a local chat and quickly loading history again', async () => { const historicalLoad = createDeferred<{ success: boolean; diff --git a/tests/unit/acp-host-contract.test.ts b/tests/unit/acp-host-contract.test.ts index 145ff85b..fe59d829 100644 --- a/tests/unit/acp-host-contract.test.ts +++ b/tests/unit/acp-host-contract.test.ts @@ -9,7 +9,6 @@ import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract'; describe('ACP Chat host contract', () => { it('exposes typed chat host methods', () => { expect(acpChatHostApiActions).toEqual([ - 'sendWithMedia', 'loadAcpSession', 'sendAcpPrompt', 'cancelAcpSession', diff --git a/tests/unit/acp-timeline-groups.test.ts b/tests/unit/acp-timeline-groups.test.ts index 2d0db9bc..f878504e 100644 --- a/tests/unit/acp-timeline-groups.test.ts +++ b/tests/unit/acp-timeline-groups.test.ts @@ -12,6 +12,21 @@ function timelineWithItems(items: AcpTimelineSnapshot['itemsById']): AcpTimeline } describe('groupAcpTimelineItems', () => { + it('preserves unchanged item references so rendered history can bail out', () => { + const item: AcpTimelineSnapshot['itemsById'][string] = { + kind: 'message-segment', + id: 'assistant-a:0', + role: 'assistant', + messageId: 'assistant-a', + segmentIndex: 0, + parts: [{ kind: 'markdown', text: 'Stable history' }], + }; + + const [group] = groupAcpTimelineItems(timelineWithItems({ [item.id]: item })); + + expect(group?.items[0]).toBe(item); + }); + it('groups assistant text, tool calls, and later assistant text into one assistant turn', () => { const groups = groupAcpTimelineItems(timelineWithItems({ 'assistant-a:0': { diff --git a/tests/unit/agent-config.test.ts b/tests/unit/agent-config.test.ts index d011132e..291eeafc 100644 --- a/tests/unit/agent-config.test.ts +++ b/tests/unit/agent-config.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { access, mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { access, lstat, mkdir, readFile, rm, symlink, writeFile } from 'fs/promises'; import { join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -185,6 +185,39 @@ describe('agent config lifecycle', () => { }); }); + it('mutates the running coordinator snapshot instead of replacing it from the local file', async () => { + await writeOpenClawJson({ localOnly: true }); + let runningConfig: Record = { + gatewayOnly: true, + agents: { + list: [{ id: 'main', name: 'Gateway Main', default: true }], + }, + }; + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string, params: unknown) => { + if (method === 'config.get') return { raw: JSON.stringify(runningConfig), hash: 'hash-1' }; + if (method === 'config.set') { + runningConfig = JSON.parse((params as { raw: string }).raw) as Record; + return { ok: true }; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const { updateAgentName } = await import('@electron/utils/agent-config'); + + const snapshot = await updateAgentName('main', 'Coordinator Main'); + + expect(runningConfig).toMatchObject({ + gatewayOnly: true, + agents: { list: [{ id: 'main', name: 'Coordinator Main', default: true }] }, + }); + expect(snapshot.agents[0].name).toBe('Coordinator Main'); + expect(await readOpenClawJson()).toEqual({ localOnly: true }); + }); + it('rejects invalid model ref formats when updating agent model', async () => { await writeOpenClawJson({ agents: { @@ -318,8 +351,8 @@ describe('agent config lifecycle', () => { ]); expect(config.bindings).toEqual([]); await expect(access(test2RuntimeDir)).rejects.toThrow(); - // Workspace deletion is intentionally deferred by `deleteAgentConfig` to avoid - // ENOENT errors during Gateway restart, so it should still exist here. + // The service removes the workspace after `deleteAgentConfig` commits, so the + // utility leaves it in place for the caller. await expect(access(test2WorkspaceDir)).resolves.toBeUndefined(); infoSpy.mockRestore(); @@ -404,6 +437,116 @@ describe('agent config lifecycle', () => { expect(feishu.accounts?.test2).toBeDefined(); }); + it('does not delete an account reassigned by a later binding', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + { id: 'test2', name: 'test2' }, + { id: 'test3', name: 'test3' }, + ], + }, + channels: { + telegram: { + accounts: { + test2: { enabled: false, botToken: 'telegram-token' }, + }, + }, + }, + bindings: [ + { agentId: 'test2', match: { channel: 'telegram', accountId: 'test2' } }, + { agentId: 'test3', match: { channel: 'telegram', accountId: 'test2' } }, + ], + }); + const { deleteAgentConfig } = await import('@electron/utils/agent-config'); + + await deleteAgentConfig('test2'); + + const config = await readOpenClawJson(); + const telegram = (config.channels as Record).telegram as { + accounts?: Record; + }; + expect(telegram.accounts?.test2).toBeDefined(); + }); + + it('deletes an owned account for a disabled channel with its agent', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + { id: 'test2', name: 'test2' }, + ], + }, + channels: { + telegram: { + enabled: false, + defaultAccount: 'test2', + accounts: { + test2: { enabled: false, botToken: 'telegram-token' }, + }, + botToken: 'telegram-token', + }, + }, + bindings: [ + { + agentId: 'test2', + match: { + channel: 'telegram', + accountId: 'test2', + }, + }, + ], + }); + const { deleteAgentConfig } = await import('@electron/utils/agent-config'); + + await deleteAgentConfig('test2'); + + const config = await readOpenClawJson(); + expect((config.channels as Record).telegram).toBeUndefined(); + }); + + it('deletes owned plugin-only credentials while preserving sibling accounts', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + { id: 'test2', name: 'test2' }, + { id: 'test3', name: 'test3' }, + ], + }, + bindings: [ + { agentId: 'test2', match: { channel: 'discord', accountId: 'test2' } }, + { agentId: 'test3', match: { channel: 'discord', accountId: 'test3' } }, + ], + plugins: { + allow: ['discord'], + entries: { + discord: { + enabled: true, + defaultAccount: 'test2', + accounts: { + test2: { enabled: true, token: 'discord-token-2' }, + test3: { enabled: true, token: 'discord-token-3' }, + }, + }, + }, + }, + }); + const { deleteAgentConfig } = await import('@electron/utils/agent-config'); + + await deleteAgentConfig('test2'); + + const config = await readOpenClawJson(); + const discord = ((config.plugins as { + entries: Record>; + }).entries).discord; + expect(discord.defaultAccount).toBe('test3'); + expect(discord.accounts).toEqual({ + test3: { enabled: true, token: 'discord-token-3' }, + }); + expect(JSON.stringify(discord)).not.toContain('discord-token-2'); + }); + it('allows the same agent to bind multiple different channels', async () => { await writeOpenClawJson({ agents: { @@ -456,6 +599,60 @@ describe('agent config lifecycle', () => { expect(snapshot.channelAccountOwners['feishu:alt']).toBe('main'); }); + it('uses a legacy channel binding for the default account alongside an explicit sibling account', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + { id: 'research', name: 'Research' }, + ], + }, + channels: { + feishu: { + enabled: true, + defaultAccount: 'default', + accounts: { + default: { enabled: true, appId: 'main-app' }, + alt: { enabled: true, appId: 'alt-app' }, + }, + }, + }, + bindings: [ + { agentId: 'main', match: { channel: 'feishu' } }, + { agentId: 'research', match: { channel: 'feishu', accountId: 'alt' } }, + ], + }); + + const { listAgentsSnapshot } = await import('@electron/utils/agent-config'); + + const snapshot = await listAgentsSnapshot(); + expect(snapshot.channelAccountOwners['feishu:default']).toBe('main'); + expect(snapshot.channelAccountOwners['feishu:alt']).toBe('research'); + }); + + it('atomically migrates a legacy binding while assigning a scoped sibling account', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + { id: 'alt', name: 'Alt' }, + ], + }, + bindings: [ + { agentId: 'main', match: { channel: 'feishu' } }, + ], + }); + const { ensureScopedChannelBinding } = await import('@electron/utils/agent-config'); + + await ensureScopedChannelBinding('feishu', 'alt'); + + const config = await readOpenClawJson(); + expect(config.bindings).toEqual([ + { agentId: 'main', match: { channel: 'feishu', accountId: 'default' } }, + { agentId: 'alt', match: { channel: 'feishu', accountId: 'alt' } }, + ]); + }); + it('preserves original agentId casing when persisting bindings', async () => { await writeOpenClawJson({ agents: { @@ -571,4 +768,41 @@ describe('agent config lifecycle', () => { await expect(readFile(join(testHome, '.openclaw', 'workspace-research', 'IDENTITY.md'), 'utf8')).resolves.toContain('ClawX'); }); + + it('rolls back a committed agent entry when filesystem provisioning fails', async () => { + await writeOpenClawJson({ + agents: { + list: [{ id: 'main', name: 'Main', default: true }], + }, + }); + const blockedWorkspace = join(testHome, '.openclaw', 'workspace-research'); + await writeFile(blockedWorkspace, 'pre-existing file', 'utf8'); + const { createAgent } = await import('@electron/utils/agent-config'); + + await expect(createAgent('Research')).rejects.toThrow(); + + const config = await readOpenClawJson(); + const agents = (config.agents as { list: Array<{ id: string }> }).list; + expect(agents.map((agent) => agent.id)).toEqual(['main']); + await expect(readFile(blockedWorkspace, 'utf8')).resolves.toBe('pre-existing file'); + await expect(access(join(testHome, '.openclaw', 'agents', 'research'))).rejects.toThrow(); + }); + + it('does not delete a pre-existing dangling workspace symlink during provisioning rollback', async () => { + await writeOpenClawJson({ + agents: { + list: [{ id: 'main', name: 'Main', default: true }], + }, + }); + const workspaceLink = join(testHome, '.openclaw', 'workspace-research'); + await symlink(join(testHome, 'missing-workspace-target'), workspaceLink); + const { createAgent } = await import('@electron/utils/agent-config'); + + await expect(createAgent('Research')).rejects.toThrow(); + + await expect(lstat(workspaceLink)).resolves.toMatchObject({}); + expect((await lstat(workspaceLink)).isSymbolicLink()).toBe(true); + const config = await readOpenClawJson(); + expect((config.agents as { list: Array<{ id: string }> }).list.map((agent) => agent.id)).toEqual(['main']); + }); }); diff --git a/tests/unit/channel-config.test.ts b/tests/unit/channel-config.test.ts index 2d3bf3b8..668d842b 100644 --- a/tests/unit/channel-config.test.ts +++ b/tests/unit/channel-config.test.ts @@ -46,6 +46,12 @@ async function readOpenClawJson(): Promise> { return JSON.parse(content) as Record; } +async function writeOpenClawJson(config: unknown): Promise { + const openclawDir = join(testHome, '.openclaw'); + await mkdir(openclawDir, { recursive: true }); + await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify(config, null, 2), 'utf8'); +} + describe('channel credential normalization and duplicate checks', () => { beforeEach(async () => { vi.resetAllMocks(); @@ -160,9 +166,9 @@ describe('WeCom plugin configuration', () => { }); it('normalizes feishu plugin registration to openclaw-lark and removes built-in feishu on save', async () => { - const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config'); + const { saveChannelConfig } = await import('@electron/utils/channel-config'); - await writeOpenClawConfig({ + await writeOpenClawJson({ plugins: { enabled: true, allow: ['custom-plugin', 'feishu', 'feishu-openclaw-plugin'], @@ -206,9 +212,9 @@ describe('WeCom plugin configuration', () => { }); it('keeps whatsapp plugin registration when saving plugin-backed config', async () => { - const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config'); + const { saveChannelConfig } = await import('@electron/utils/channel-config'); - await writeOpenClawConfig({ + await writeOpenClawJson({ plugins: { enabled: true, allow: ['whatsapp'], @@ -271,9 +277,9 @@ describe('WeCom plugin configuration', () => { }); it('sanitizes legacy discord guild channel allow flags before writing', async () => { - const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config'); + const { saveChannelConfig } = await import('@electron/utils/channel-config'); - await writeOpenClawConfig({ + await writeOpenClawJson({ channels: { discord: { enabled: true, @@ -315,6 +321,124 @@ describe('WeCom plugin configuration', () => { expect(channels.discord.guilds?.['1438451181474287618'].channels?.['*']).not.toHaveProperty('allow'); expect(channels.discord.accounts?.default.guilds?.['1438451181474287618'].channels?.['*']).not.toHaveProperty('allow'); }); + + it('deletes a custom default account without recreating it as the literal default account', async () => { + await writeOpenClawJson({ + channels: { + telegram: { + accounts: { + 'agent-a': { botToken: 'telegram-token', enabled: false }, + }, + defaultAccount: 'agent-a', + enabled: false, + botToken: 'telegram-token', + }, + }, + }); + const { deleteChannelAccountConfig } = await import('@electron/utils/channel-config'); + + await deleteChannelAccountConfig('telegram', 'agent-a'); + + const config = await readOpenClawJson(); + expect((config.channels as Record).telegram).toBeUndefined(); + }); + + it('deletes an agent-owned custom default account without recreating it', async () => { + await writeOpenClawJson({ + channels: { + telegram: { + accounts: { + 'agent-a': { botToken: 'telegram-token', enabled: false }, + }, + defaultAccount: 'agent-a', + enabled: false, + botToken: 'telegram-token', + }, + }, + }); + const { deleteAgentChannelAccounts } = await import('@electron/utils/channel-config'); + + await deleteAgentChannelAccounts('agent-a', new Set(['telegram:agent-a'])); + + const config = await readOpenClawJson(); + expect((config.channels as Record).telegram).toBeUndefined(); + }); + + it('removes deleted plugin-backed account credentials from the plugin mirror', async () => { + await writeOpenClawJson({ + channels: { + discord: { + accounts: { + 'agent-a': { token: 'discord-token-a', enabled: true }, + 'agent-b': { token: 'discord-token-b', enabled: true }, + }, + defaultAccount: 'agent-a', + enabled: true, + token: 'discord-token-a', + }, + }, + plugins: { + allow: ['discord'], + entries: { + discord: { + enabled: true, + defaultAccount: 'agent-a', + accounts: { + 'agent-a': { token: 'discord-token-a', enabled: true }, + 'agent-b': { token: 'discord-token-b', enabled: true }, + }, + }, + }, + }, + }); + const { deleteChannelAccountConfig } = await import('@electron/utils/channel-config'); + + await deleteChannelAccountConfig('discord', 'agent-a'); + + const config = await readOpenClawJson(); + const channel = (config.channels as Record>).discord; + const plugin = ((config.plugins as { entries: Record> }).entries).discord; + expect(channel.defaultAccount).toBe('agent-b'); + expect(channel.accounts).toEqual({ + 'agent-b': { token: 'discord-token-b', enabled: true }, + }); + expect(plugin.defaultAccount).toBe('agent-b'); + expect(plugin.accounts).toEqual({ + 'agent-b': { token: 'discord-token-b', enabled: true }, + }); + expect(JSON.stringify(plugin)).not.toContain('discord-token-a'); + }); + + it('removes plugin-only account credentials while preserving sibling accounts', async () => { + await writeOpenClawJson({ + plugins: { + allow: ['discord'], + entries: { + discord: { + enabled: true, + defaultAccount: 'agent-a', + accounts: { + 'agent-a': { token: 'discord-token-a', enabled: true }, + 'agent-b': { token: 'discord-token-b', enabled: true }, + }, + }, + }, + }, + }); + const { deleteChannelAccountConfig } = await import('@electron/utils/channel-config'); + + await deleteChannelAccountConfig('discord', 'agent-a'); + + const config = await readOpenClawJson(); + const plugin = ((config.plugins as { + entries: Record>; + }).entries).discord; + expect(plugin.defaultAccount).toBe('agent-b'); + expect(plugin.accounts).toEqual({ + 'agent-b': { token: 'discord-token-b', enabled: true }, + }); + expect(JSON.stringify(plugin)).not.toContain('discord-token-a'); + }); }); describe('WeChat dangling plugin cleanup', () => { @@ -326,9 +450,9 @@ describe('WeChat dangling plugin cleanup', () => { }); it('removes dangling openclaw-weixin plugin registration and state when no channel config exists', async () => { - const { cleanupDanglingWeChatPluginState, writeOpenClawConfig } = await import('@electron/utils/channel-config'); + const { cleanupDanglingWeChatPluginState } = await import('@electron/utils/channel-config'); - await writeOpenClawConfig({ + await writeOpenClawJson({ plugins: { enabled: true, allow: ['openclaw-weixin'], @@ -352,6 +476,95 @@ describe('WeChat dangling plugin cleanup', () => { }); }); +describe('coordinated channel config delivery', () => { + beforeEach(async () => { + vi.resetAllMocks(); + vi.resetModules(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('mutates the running coordinator snapshot without replacing it from the local file', async () => { + await writeOpenClawJson({ localOnly: true }); + let runningConfig: Record = { gatewayOnly: true }; + let hash = 'hash-1'; + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string, params: unknown) => { + if (method === 'config.get') return { raw: JSON.stringify(runningConfig), hash }; + if (method === 'config.set') { + runningConfig = JSON.parse((params as { raw: string }).raw) as Record; + hash = 'hash-2'; + return { ok: true }; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const channelConfig = await import('@electron/utils/channel-config'); + + await channelConfig.saveChannelConfig('telegram', { botToken: 'gateway-token' }, 'default'); + + expect(runningConfig).toMatchObject({ + gatewayOnly: true, + channels: { + telegram: { + accounts: { default: { botToken: 'gateway-token' } }, + }, + }, + }); + expect(await readOpenClawJson()).toEqual({ localOnly: true }); + expect(channelConfig).not.toHaveProperty('writeOpenClawConfig'); + }); + + it('reads the resolved OpenClaw config path', async () => { + const customPath = join(testHome, 'custom', 'runtime.json'); + await mkdir(join(testHome, 'custom'), { recursive: true }); + await writeFile(customPath, JSON.stringify({ customPath: true }), 'utf8'); + process.env.OPENCLAW_CONFIG_PATH = customPath; + + try { + const { readOpenClawConfig } = await import('@electron/utils/channel-config'); + await expect(readOpenClawConfig()).resolves.toEqual({ customPath: true }); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + } + }); + + it('reads the running coordinator snapshot instead of the local file', async () => { + await writeOpenClawJson({ localOnly: true }); + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string) => { + if (method === 'config.get') { + return { raw: '{ gatewayOnly: true }', hash: 'hash-1' }; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const { readOpenClawConfig } = await import('@electron/utils/channel-config'); + + await expect(readOpenClawConfig()).resolves.toEqual({ gatewayOnly: true }); + }); + + it('accepts JSON5 syntax when reading the resolved OpenClaw config path', async () => { + const customPath = join(testHome, 'custom', 'runtime.json5'); + await mkdir(join(testHome, 'custom'), { recursive: true }); + await writeFile(customPath, '{\n // comment\n customPath: true,\n}\n', 'utf8'); + process.env.OPENCLAW_CONFIG_PATH = customPath; + + try { + const { readOpenClawConfig } = await import('@electron/utils/channel-config'); + await expect(readOpenClawConfig()).resolves.toEqual({ customPath: true }); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + } + }); +}); + describe('configured channel account extraction', () => { beforeEach(async () => { vi.resetAllMocks(); diff --git a/tests/unit/chat-page-execution-graph.test.tsx b/tests/unit/chat-acp-inline-timeline.test.tsx similarity index 94% rename from tests/unit/chat-page-execution-graph.test.tsx rename to tests/unit/chat-acp-inline-timeline.test.tsx index 65dad42f..2d5bde61 100644 --- a/tests/unit/chat-page-execution-graph.test.tsx +++ b/tests/unit/chat-acp-inline-timeline.test.tsx @@ -251,13 +251,12 @@ describe('ACP Chat page inline timeline lifecycle', () => { stickState.scrollToBottom.mockReset(); }); - it('renders ACP process blocks inline instead of the legacy execution graph', async () => { + it('renders ACP process blocks in timeline order', async () => { const { Chat } = await import('@/pages/Chat/index'); const { container } = render(); expect(screen.getByTestId('acp-chat-timeline')).toBeInTheDocument(); - expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument(); expect(screen.getByTestId('acp-thought-block')).toHaveTextContent('Need to inspect the current implementation first.'); expect(screen.getByTestId('acp-tool-call-card')).toHaveTextContent('Read file'); expect(screen.getByTestId('acp-tool-call-card')).toHaveTextContent('Loaded src/pages/Chat/index.tsx'); @@ -307,20 +306,16 @@ describe('ACP Chat page inline timeline lifecycle', () => { expect(screen.getByTestId('acp-chat-timeline')).toBeInTheDocument(); expect(screen.getByTestId('acp-tool-call-card')).toHaveTextContent('Read file'); expect(screen.getByTestId('acp-tool-call-card')).toHaveTextContent('Running'); - expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument(); expect(screen.getByTestId('mock-chat-input')).toHaveAttribute('data-sending', 'true'); }); - it('renders ACP load errors as inline timeline errors without the graph trailing state', async () => { + it('renders ACP load errors as inline timeline errors', async () => { acpState.error = '404 Resource not found'; const { Chat } = await import('@/pages/Chat/index'); render(); expect(screen.getByTestId('acp-error-banner')).toHaveTextContent('404 Resource not found'); - expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument(); - expect(screen.queryByTestId('chat-execution-step-thinking-trailing')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); expect(acpState.clearError).toHaveBeenCalledTimes(1); @@ -340,7 +335,7 @@ describe('ACP Chat page inline timeline lifecycle', () => { expect(stickState.scrollToBottom).toHaveBeenCalledWith({ animation: 'smooth', ignoreEscapes: true }); }); - it('renders a nonblank ACP empty state instead of an empty execution graph', async () => { + it('renders a nonblank ACP empty state', async () => { acpState.timeline = emptyTimeline(); const { Chat } = await import('@/pages/Chat/index'); @@ -348,6 +343,5 @@ describe('ACP Chat page inline timeline lifecycle', () => { expect(screen.getByTestId('acp-chat-empty-state')).toHaveTextContent('What can I do for you?'); expect(screen.queryByTestId('acp-chat-timeline')).not.toBeInTheDocument(); - expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument(); }); }); diff --git a/tests/unit/chat-acp-page.test.tsx b/tests/unit/chat-acp-page.test.tsx index 67d27db7..aaca0a36 100644 --- a/tests/unit/chat-acp-page.test.tsx +++ b/tests/unit/chat-acp-page.test.tsx @@ -71,31 +71,13 @@ const { acpState, agentsState, artifactPanelState, artifactPanelProps, chatState }, artifactPanelProps: [] as Array<{ fileGroups: unknown[]; uniqueFileCount: number; agent: unknown; runStartedAt?: number | null }>, chatState: { - messages: [], sessions: [], currentSessionKey: 'agent:main:main', currentAgentId: 'main', sessionLabels: {}, - loading: false, - loadingMoreHistory: false, - hasMoreHistory: false, - sending: false, - error: null, - runError: null, - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - activeRunId: null, - runtimeRuns: {}, - sendMessage: vi.fn(), loadSessions: vi.fn(), selectAcpSession: vi.fn(), acknowledgeAcpSessionCreated: vi.fn(), - abortRun: vi.fn(), - clearError: vi.fn(), - loadMoreHistory: vi.fn(), - cleanupEmptySession: vi.fn(), - lastUserMessageAt: null, }, gatewayState: { status: { state: 'running', gatewayReady: true, port: 18789 }, @@ -225,10 +207,6 @@ vi.mock('@/components/file-preview/PanelResizeDivider', () => ({ PanelResizeDivider: () => null, })); -vi.mock('@/pages/Chat/ExecutionGraphCard', () => ({ - ExecutionGraphCard: () =>
, -})); - vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: string | Record) => { @@ -375,7 +353,7 @@ describe('ACP Chat page', () => { : session )); } else { - chatState.sessions = [...chatState.sessions, { key: sessionKey, workspacePath }]; + chatState.sessions = [...chatState.sessions, { key: sessionKey, workspacePath, createdLocally: true }]; } }); chatState.acknowledgeAcpSessionCreated.mockReset(); @@ -384,11 +362,10 @@ describe('ACP Chat page', () => { gatewayState.status = { state: 'running', gatewayReady: true, port: 18789 }; }); - it('renders ACP inline timeline content instead of the execution graph', async () => { + it('renders ACP inline timeline content', async () => { const { container } = render(); expect(screen.getByTestId('acp-chat-timeline')).toBeInTheDocument(); - expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument(); expect(screen.getByText('List project files')).toBeInTheDocument(); expect(screen.getByTestId('acp-tool-call-card')).toHaveTextContent('List files'); expect(screen.getByTestId('acp-tool-call-card')).toHaveTextContent('src/pages/Chat/index.tsx'); @@ -467,6 +444,151 @@ describe('ACP Chat page', () => { expect(acpState.loadSession).toHaveBeenCalledWith({ sessionKey: 'agent:main:main', workspaceRoot: '/workspace', cwd: '/workspace', createIfMissing: true, }); + expect(chatState.acknowledgeAcpSessionCreated).toHaveBeenCalledWith( + 'agent:main:main', + '/workspace', + ); + }); + }); + + it('does not load ACP until session discovery publishes the hydrated workspace', async () => { + const sessionKey = 'agent:main:hydrated-workspace'; + let finishDiscovery!: () => void; + acpState.activeSessionKey = null; + chatState.sessions = []; + chatState.currentSessionKey = 'agent:main:main'; + chatState.loadSessions.mockImplementation(() => new Promise((resolve) => { + finishDiscovery = () => { + chatState.currentSessionKey = sessionKey; + chatState.sessions = [{ key: sessionKey, workspacePath: '/hydrated-workspace' }]; + resolve(); + }; + })); + + render(); + + await waitFor(() => expect(chatState.loadSessions).toHaveBeenCalledTimes(1)); + expect(acpState.loadSession).not.toHaveBeenCalled(); + + finishDiscovery(); + await waitFor(() => { + expect(acpState.loadSession).toHaveBeenCalledWith({ + sessionKey, + workspaceRoot: '/hydrated-workspace', + cwd: '/hydrated-workspace', + }); + }); + }); + + it('preserves an optimistic absent session when automatic ACP creation fails during catalog reconciliation', async () => { + const sessionKey = 'agent:main:session-auto-missing'; + const fallbackSessionKey = 'agent:main:fallback'; + let finishCatalog!: () => void; + chatState.sessions = []; + chatState.currentSessionKey = sessionKey; + acpState.activeSessionKey = null; + acpState.workspaceRoot = null; + acpState.cwd = null; + acpState.timeline = { ...emptyTimeline(), sessionId: sessionKey }; + acpState.loadSession.mockResolvedValue(false); + chatState.loadSessions.mockImplementation(() => new Promise((resolve) => { + finishCatalog = () => { + const optimisticSession = chatState.sessions.find((session) => session.key === sessionKey); + if (optimisticSession?.workspacePath) { + chatState.sessions = [{ ...optimisticSession, displayName: 'Reconciled automatic session' }]; + } else { + chatState.currentSessionKey = fallbackSessionKey; + chatState.sessions = [{ key: fallbackSessionKey, workspacePath: '/fallback' }]; + } + resolve(); + }; + })); + const catalogRequest = chatState.loadSessions(); + + render(); + + await waitFor(() => { + expect(chatState.selectAcpSession).toHaveBeenCalledWith(sessionKey, '/workspace'); + expect(acpState.loadSession).toHaveBeenCalledWith({ + sessionKey, + workspaceRoot: '/workspace', + cwd: '/workspace', + createIfMissing: true, + }); + }); + expect(chatState.acknowledgeAcpSessionCreated).not.toHaveBeenCalled(); + expect(chatState.selectAcpSession.mock.invocationCallOrder.at(-1)!).toBeLessThan( + acpState.loadSession.mock.invocationCallOrder.at(-1)!, + ); + + finishCatalog(); + await catalogRequest; + expect(chatState.currentSessionKey).toBe(sessionKey); + expect(chatState.sessions).toEqual([{ + key: sessionKey, + workspacePath: '/workspace', + createdLocally: true, + displayName: 'Reconciled automatic session', + }]); + }); + + it('defers placeholder creation until send and retries failed first-send ACP creation', async () => { + const sessionKey = 'agent:main:session-auto-retry'; + chatState.sessions = [{ + key: sessionKey, + displayName: sessionKey, + workspacePath: '/workspace', + createdLocally: true, + }]; + chatState.currentSessionKey = sessionKey; + acpState.activeSessionKey = null; + acpState.workspaceRoot = null; + acpState.cwd = null; + acpState.timeline = { ...emptyTimeline(), sessionId: sessionKey }; + acpState.loadSession + .mockResolvedValueOnce(false) + .mockImplementation(async (input: { sessionKey: string; workspaceRoot: string; cwd: string }) => { + acpState.activeSessionKey = input.sessionKey; + acpState.workspaceRoot = input.workspaceRoot; + acpState.cwd = input.cwd; + return true; + }); + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId('mock-chat-input')).toHaveAttribute('data-disabled', 'false'); + }); + expect(acpState.loadSession).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('mock-send')); + await waitFor(() => expect(acpState.loadSession).toHaveBeenCalledTimes(1)); + expect(acpState.loadSession).toHaveBeenLastCalledWith({ + sessionKey, + workspaceRoot: '/workspace', + cwd: '/workspace', + createIfMissing: true, + }); + expect(chatState.acknowledgeAcpSessionCreated).not.toHaveBeenCalled(); + expect(acpState.sendPrompt).not.toHaveBeenCalled(); + + rerender(); + fireEvent.click(screen.getByTestId('mock-send')); + + await waitFor(() => { + expect(acpState.loadSession).toHaveBeenCalledTimes(2); + expect(acpState.loadSession).toHaveBeenLastCalledWith({ + sessionKey, + workspaceRoot: '/workspace', + cwd: '/workspace', + createIfMissing: true, + }); + expect(chatState.acknowledgeAcpSessionCreated).toHaveBeenCalledWith( + sessionKey, + '/workspace', + 'Ship it', + ); + expect(acpState.sendPrompt).toHaveBeenCalledWith(expect.objectContaining({ sessionKey })); }); }); @@ -726,6 +848,40 @@ describe('ACP Chat page', () => { expect(screen.getByTestId('mock-chat-input')).toHaveAttribute('data-disabled', 'true'); }); + it('optimistically binds an absent current session before first-send ACP creation', async () => { + const sessionKey = 'agent:main:session-send-missing'; + chatState.sessions = []; + chatState.currentSessionKey = sessionKey; + acpState.activeSessionKey = sessionKey; + acpState.workspaceRoot = '/workspace'; + acpState.cwd = '/workspace'; + acpState.timeline = { ...emptyTimeline(), sessionId: sessionKey }; + render(); + await waitFor(() => expect(resolveWorkspaceContext).toHaveBeenCalled()); + acpState.loadSession.mockClear(); + chatState.selectAcpSession.mockClear(); + + fireEvent.click(screen.getByTestId('mock-send')); + + await waitFor(() => { + expect(chatState.selectAcpSession).toHaveBeenCalledWith(sessionKey, '/workspace'); + expect(acpState.loadSession).toHaveBeenCalledWith({ + sessionKey, + workspaceRoot: '/workspace', + cwd: '/workspace', + createIfMissing: true, + }); + expect(chatState.acknowledgeAcpSessionCreated).toHaveBeenCalledWith( + sessionKey, + '/workspace', + 'Ship it', + ); + }); + expect(chatState.selectAcpSession.mock.invocationCallOrder.at(-1)!).toBeLessThan( + acpState.loadSession.mock.invocationCallOrder.at(-1)!, + ); + }); + it('loads ACP sessions and keeps the composer enabled while Gateway is stopped', async () => { gatewayState.status = { state: 'stopped', gatewayReady: false, port: 18789 }; diff --git a/tests/unit/chat-artifact-panel-layout.test.tsx b/tests/unit/chat-artifact-panel-layout.test.tsx index 3e31e40d..c4f47bb7 100644 --- a/tests/unit/chat-artifact-panel-layout.test.tsx +++ b/tests/unit/chat-artifact-panel-layout.test.tsx @@ -24,32 +24,13 @@ vi.mock('@/lib/host-api', () => ({ })); const chatState = { - messages: [], sessions: [{ key: 'main:test' }], currentSessionKey: 'main:test', currentAgentId: 'main', sessionLabels: {}, - loading: false, - loadingMoreHistory: false, - hasMoreHistory: false, - sending: false, - error: null, - runError: null, - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - activeRunId: null, - sendMessage: vi.fn(), - abortRun: vi.fn(), - clearError: vi.fn(), loadSessions: vi.fn().mockResolvedValue(undefined), selectAcpSession: vi.fn(), acknowledgeAcpSessionCreated: vi.fn(), - loadMoreHistory: vi.fn(), - loadHistory: vi.fn(), - refresh: vi.fn(), - cleanupEmptySession: vi.fn(), - lastUserMessageAt: null, }; vi.mock('@/stores/chat', () => ({ diff --git a/tests/unit/chat-assistant-media-display.test.ts b/tests/unit/chat-assistant-media-display.test.ts deleted file mode 100644 index 0346de15..00000000 --- a/tests/unit/chat-assistant-media-display.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractText, sanitizeAssistantReplyText } from '@/pages/Chat/message-utils'; - -describe('assistant media path display cleanup', () => { - it('strips bare OpenClaw media paths when the image is shown as an attachment card', () => { - const text = [ - '宇航员图片生成完成啦 🧑‍🚀✨', - '/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---82d6c7e6-ea44-4850-a24b-9e88e1660683.png', - ].join('\n'); - - expect(extractText({ role: 'assistant', content: text })).toBe('宇航员图片生成完成啦 🧑‍🚀✨'); - }); - - it('still strips MEDIA: tagged OpenClaw artifact paths', () => { - const text = 'Done:\n\nMEDIA:/Users/alice/.openclaw/media/outbound/cat---abc.png'; - - expect(extractText({ role: 'assistant', content: text })).toBe('Done:'); - }); - - it('strips MEDIA: tagged Windows artifact paths', () => { - const text = String.raw`SVG file is ready: - -MEDIA:C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`; - - expect(extractText({ role: 'assistant', content: text })).toBe('SVG file is ready:'); - }); - - it('strips bare Windows OpenClaw media paths when surfaced as attachment cards', () => { - const text = String.raw`Done: -C:\Users\alice\.openclaw\media\outbound\cat---abc.png`; - - expect(extractText({ role: 'assistant', content: text })).toBe('Done:'); - }); - - it('strips markdown image syntax that cannot be rendered directly', () => { - const text = '宇航员图片完成啦 🧑‍🚀✨\n\n![Astronaut with Milky Way in helmet visor](/api/chat/media/outgoing/agent%3Amain%3As-1/abc/full)'; - - expect(extractText({ role: 'assistant', content: text })).toBe('宇航员图片完成啦 🧑‍🚀✨'); - }); - - it('strips internal delivery-planning narration before the user-facing caption', () => { - const text = [ - "The message tool isn't suitable here since I'm in a webchat session with no proper routing target. The runtime context says 'Use the current visible-reply contract... Otherwise, write the normal final reply and attach every generated media path with final-reply MEDIA lines.' Since webchat isn't a valid channel for the message tool, I should fall back to writing the normal final reply with MEDIA directives.", - '西瓜切片来了 🍉 红透多汁,夏日续命必备~', - 'MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---4d2c1ef7-0d16-451c-9c09-9b58c4c99846.png', - ].join('\n\n'); - - expect(extractText({ role: 'assistant', content: text })).toBe('西瓜切片来了 🍉 红透多汁,夏日续命必备~'); - expect(sanitizeAssistantReplyText(text)).toBe('西瓜切片来了 🍉 红透多汁,夏日续命必备~'); - }); - - it('strips inline MEDIA: markers glued to caption punctuation', () => { - const text = - '橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png'; - - expect(extractText({ role: 'assistant', content: text })).toBe('橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~'); - }); -}); diff --git a/tests/unit/chat-event-dedupe.test.ts b/tests/unit/chat-event-dedupe.test.ts deleted file mode 100644 index a922ab72..00000000 --- a/tests/unit/chat-event-dedupe.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { extractText } from '@/pages/Chat/message-utils'; - -const { gatewayRpcMock, hostApiFetchMock, agentsState } = vi.hoisted(() => ({ - gatewayRpcMock: vi.fn(), - hostApiFetchMock: vi.fn(), - agentsState: { - agents: [] as Array>, - }, -})); - -vi.mock('@/stores/gateway', () => ({ - useGatewayStore: { - getState: () => ({ - status: { state: 'running', port: 18789 }, - rpc: gatewayRpcMock, - }), - }, -})); - -vi.mock('@/stores/agents', () => ({ - useAgentsStore: { - getState: () => agentsState, - }, -})); - -vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), -})); - -describe('chat event dedupe', () => { - beforeEach(() => { - vi.resetModules(); - window.localStorage.clear(); - gatewayRpcMock.mockReset(); - hostApiFetchMock.mockReset(); - agentsState.agents = []; - }); - - it('keeps processing delta events without seq for the same run', async () => { - const { useChatStore } = await import('@/stores/chat'); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: true, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - useChatStore.getState().handleChatEvent({ - state: 'delta', - runId: 'run-no-seq', - sessionKey: 'agent:main:main', - message: { - role: 'assistant', - id: 'reply-stream', - content: [{ type: 'text', text: 'Checked X.' }], - }, - }); - - useChatStore.getState().handleChatEvent({ - state: 'delta', - runId: 'run-no-seq', - sessionKey: 'agent:main:main', - message: { - role: 'assistant', - id: 'reply-stream', - content: [ - { type: 'text', text: 'Checked X.' }, - { type: 'text', text: 'Checked X. Here is the summary.' }, - ], - }, - }); - - expect(extractText(useChatStore.getState().streamingMessage)).toBe('Checked X. Here is the summary.'); - }); - - it('still dedupes repeated delta events when seq matches', async () => { - const { useChatStore } = await import('@/stores/chat'); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - useChatStore.getState().handleChatEvent({ - state: 'delta', - runId: 'run-with-seq', - sessionKey: 'agent:main:main', - seq: 3, - message: { - role: 'assistant', - id: 'reply-stream', - content: [{ type: 'text', text: 'first version' }], - }, - }); - - useChatStore.getState().handleChatEvent({ - state: 'delta', - runId: 'run-with-seq', - sessionKey: 'agent:main:main', - seq: 3, - message: { - role: 'assistant', - id: 'reply-stream', - content: [{ type: 'text', text: 'duplicate version should be ignored' }], - }, - }); - - expect(extractText(useChatStore.getState().streamingMessage)).toBe('first version'); - }); -}); diff --git a/tests/unit/chat-helpers-enrichment.test.ts b/tests/unit/chat-helpers-enrichment.test.ts deleted file mode 100644 index 9177d7a2..00000000 --- a/tests/unit/chat-helpers-enrichment.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - enrichWithToolResultFiles, - enrichWithToolCallAttachments, - enrichWithCachedImages, - loadMissingPreviews, - shouldDropMessageFromHistory, -} from '@/stores/chat/helpers'; -import type { RawMessage } from '@/stores/chat'; - -const thumbnailsMock = vi.hoisted(() => vi.fn()); - -vi.mock('@/lib/host-api', () => ({ - hostApi: { - media: { - thumbnails: (...args: unknown[]) => thumbnailsMock(...args), - }, - }, -})); - -beforeEach(() => { - thumbnailsMock.mockReset(); -}); - -describe('enrichWithToolResultFiles', () => { - it('does not promote image content blocks emitted by `read` tool results', () => { - // The `read` tool re-encodes the file as JPEG so the model can "see" it. - // The resulting image-data block is internal vision data, NOT a - // user-facing artifact — it must NOT spill onto the next assistant - // message as an attachment, otherwise every screenshot the agent - // inspects would render in the chat. - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'a1', - content: [{ type: 'toolCall', id: 'tc1', name: 'read', input: { path: '/tmp/foo.png' } }], - }, - { - role: 'toolresult', - id: 't1', - toolCallId: 'tc1', - toolName: 'read', - content: [ - { type: 'text', text: 'Read image file [image/jpeg]\n[Image: ...]' }, - { type: 'image', data: 'BASE64_BYTES_HERE', mimeType: 'image/jpeg' }, - ], - }, - { - role: 'assistant', - id: 'a2', - content: [{ type: 'text', text: 'I had a look at the screenshot.' }], - }, - ]; - - const enriched = enrichWithToolResultFiles(messages); - const reply = enriched.find((m) => m.id === 'a2')!; - expect(reply._attachedFiles ?? []).toEqual([]); - }); - - it('does not promote raw image paths from tool result stdout (sips / ls / file)', () => { - // `sips ... && ls -la *.jpg` etc. spam image paths in the tool's - // stdout. Each one used to surface as an `_attachedFiles` entry on - // the next assistant message, causing the canonical artifact to be - // duplicated 3-4 times per send. - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'a1', - content: [{ type: 'toolCall', id: 'tc1', name: 'exec', input: { command: 'sips ... && ls -la' } }], - }, - { - role: 'toolresult', - id: 't1', - toolCallId: 'tc1', - toolName: 'exec', - content: [{ - type: 'text', - text: '/private/tmp/desktop_screenshot.png\n /private/tmp/desktop_screenshot.jpg\n-rw-r--r--@ 1 me staff 857671 May 6 18:05 /tmp/desktop_screenshot.jpg', - }], - }, - { - role: 'assistant', - id: 'a2', - content: [{ type: 'text', text: 'Compressed to 837KB, sending again.' }], - }, - ]; - - const enriched = enrichWithToolResultFiles(messages); - const reply = enriched.find((m) => m.id === 'a2')!; - expect(reply._attachedFiles ?? []).toEqual([]); - }); - - it('still promotes non-image artifact paths from tool results (PDF / XLSX)', () => { - // Documents emitted by tools (e.g. a Python script that wrote a - // spreadsheet to disk) ARE user-facing — they remain surfaced. - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'a1', - content: [{ type: 'toolCall', id: 'tc1', name: 'exec', input: { command: '...' } }], - }, - { - role: 'toolresult', - id: 't1', - toolCallId: 'tc1', - toolName: 'exec', - content: [{ type: 'text', text: 'Saved report at /tmp/report.pdf and data at /tmp/sales.xlsx' }], - }, - { - role: 'assistant', - id: 'a2', - content: [{ type: 'text', text: 'Generated.' }], - }, - ]; - - const enriched = enrichWithToolResultFiles(messages); - const reply = enriched.find((m) => m.id === 'a2')!; - const paths = (reply._attachedFiles ?? []).map((f) => f.filePath); - expect(paths).toEqual(expect.arrayContaining(['/tmp/report.pdf', '/tmp/sales.xlsx'])); - expect(paths.find((p) => p?.endsWith('.png'))).toBeUndefined(); - expect(paths.find((p) => p?.endsWith('.jpg'))).toBeUndefined(); - }); - - it('still promotes [media attached: ...] references emitted in tool results', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'a1', - content: [{ type: 'toolCall', id: 'tc1', name: 'fetch', input: {} }], - }, - { - role: 'toolresult', - id: 't1', - toolCallId: 'tc1', - toolName: 'fetch', - content: [{ - type: 'text', - text: 'Done [media attached: /tmp/foo.pdf (application/pdf) | /tmp/foo.pdf]', - }], - }, - { - role: 'assistant', - id: 'a2', - content: [{ type: 'text', text: 'Here it is.' }], - }, - ]; - - const enriched = enrichWithToolResultFiles(messages); - const reply = enriched.find((m) => m.id === 'a2')!; - const paths = (reply._attachedFiles ?? []).map((f) => f.filePath); - expect(paths).toContain('/tmp/foo.pdf'); - }); - - it('skips internal NO_REPLY turns when attaching pending tool-result files', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'a1', - content: [{ type: 'toolCall', id: 'tc1', name: 'exec', input: { command: 'echo report > /tmp/report.pdf' } }], - }, - { - role: 'toolresult', - id: 't1', - toolCallId: 'tc1', - toolName: 'exec', - content: [{ type: 'text', text: 'Wrote /tmp/report.pdf' }], - }, - { - role: 'assistant', - id: 'no-reply', - content: [{ type: 'text', text: 'NO_REPLY' }], - }, - { - role: 'assistant', - id: 'final', - content: [{ type: 'text', text: 'Report is ready.' }], - }, - ]; - - const enriched = enrichWithToolResultFiles(messages); - const final = enriched.find((m) => m.id === 'final')!; - expect(final._attachedFiles?.map((file) => file.filePath)).toEqual(['/tmp/report.pdf']); - expect(enriched.find((m) => m.id === 'no-reply')?._attachedFiles ?? []).toEqual([]); - }); - - it('attaches delivered message-tool image media to the calling assistant turn', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'send-image', - content: [{ - type: 'toolCall', - id: 'tool-1', - name: 'message', - arguments: { - action: 'send', - message: 'image ready', - }, - }], - }, - { - role: 'toolresult', - id: 'tool-result', - toolCallId: 'tool-1', - toolName: 'message', - content: [{ type: 'text', text: '{ "status": "ok" }' }], - details: { - status: 'ok', - deliveryStatus: 'sent', - mediaUrl: '/Users/me/.openclaw/media/tool-image-generation/tomato.png', - }, - } as RawMessage, - ]; - - const enriched = enrichWithToolResultFiles(messages); - expect(enriched[0]?._attachedFiles?.map((file) => file.filePath)).toEqual([ - '/Users/me/.openclaw/media/tool-image-generation/tomato.png', - ]); - expect(enriched[0]?._attachedFiles?.[0]?.source).toBe('tool-result'); - }); - - it('surfaces the production message-tool delivery shape after history filtering', () => { - const imagePath = '/Users/me/.openclaw/media/tool-image-generation/puppy.png'; - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'send-image', - content: [ - { type: 'text', text: "I'll deliver the puppy image via the message tool." }, - { - type: 'toolCall', - id: 'tool-1', - name: 'message', - arguments: { - action: 'send', - message: 'Puppy ready', - attachments: [{ type: 'image', path: imagePath, name: 'puppy.png' }], - }, - }, - ], - }, - { - role: 'toolResult', - id: 'tool-result', - toolCallId: 'tool-1', - toolName: 'message', - content: [{ type: 'text', text: '{ "status": "ok", "deliveryStatus": "sent" }' }], - details: { - status: 'ok', - deliveryStatus: 'sent', - sourceReplySink: 'internal-ui', - sourceReply: { - text: 'Puppy ready', - mediaUrl: imagePath, - mediaUrls: [imagePath], - }, - mediaUrl: imagePath, - mediaUrls: [imagePath], - }, - } as RawMessage, - ]; - - const enriched = enrichWithToolCallAttachments(enrichWithToolResultFiles(messages)) - .filter((message) => !shouldDropMessageFromHistory(message)); - - expect(enriched).toHaveLength(2); - expect(enriched[0]?._attachedFiles ?? []).toEqual([]); - expect(enriched[1]).toMatchObject({ - role: 'assistant', - content: [{ type: 'text', text: 'Puppy ready' }], - _attachedFiles: [expect.objectContaining({ filePath: imagePath })], - }); - }); - - it('surfaces text-only internal UI message-tool deliveries', () => { - const messages: RawMessage[] = [ - { - role: 'toolResult', - id: 'message-result', - toolName: 'message', - content: [{ type: 'text', text: '{ "status": "ok" }' }], - details: { - status: 'ok', - sourceReplySink: 'internal-ui', - sourceReply: { text: 'Image generation timed out.' }, - }, - } as RawMessage, - ]; - - const enriched = enrichWithToolResultFiles(messages) - .filter((message) => !shouldDropMessageFromHistory(message)); - - expect(enriched).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: [{ type: 'text', text: 'Image generation timed out.' }], - }), - ]); - }); -}); - -describe('enrichWithCachedImages — Gateway media bubble dedup', () => { - it('drops image-typed MEDIA: refs on the reply when the next message is a Gateway assistant-media bubble', () => { - // When the agent emits `MEDIA:/tmp/x.png` the Gateway answers with a - // dedicated `assistant-media` bubble. Surfacing the same image again - // on the prior reply text would render two copies of the screenshot. - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'reply', - content: [{ type: 'text', text: 'Compressed to 837KB:\n\nMEDIA:/tmp/desktop_screenshot.jpg' }], - }, - { - role: 'assistant', - id: 'gateway-media', - content: [{ - type: 'image', - url: '/api/chat/media/outgoing/agent%3Amain%3As-1/abc-123/full', - mimeType: 'image/jpeg', - alt: 'desktop_screenshot.jpg', - }], - }, - ]; - - const enriched = enrichWithCachedImages(messages); - const reply = enriched.find((m) => m.id === 'reply')!; - const replyPaths = (reply._attachedFiles ?? []).map((f) => f.filePath); - expect(replyPaths).toEqual([]); - - const bubble = enriched.find((m) => m.id === 'gateway-media')!; - const bubbleEntries = bubble._attachedFiles ?? []; - expect(bubbleEntries).toHaveLength(1); - expect(bubbleEntries[0]).toMatchObject({ - gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/abc-123/full', - mimeType: 'image/jpeg', - source: 'gateway-media', - }); - }); - - it('keeps non-image MEDIA: refs on the reply even when a Gateway bubble follows', () => { - // Documents do not benefit from the Gateway's image pipeline; they - // should still render as inline cards on the reply text. - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'reply', - content: [{ - type: 'text', - text: 'Report generated:\n\nMEDIA:/tmp/report.pdf\n\nMEDIA:/tmp/cover.png', - }], - }, - { - role: 'assistant', - id: 'gateway-media', - content: [{ - type: 'image', - url: '/api/chat/media/outgoing/agent%3Amain%3As-1/cover-id/full', - mimeType: 'image/png', - alt: 'cover.png', - }], - }, - ]; - - const enriched = enrichWithCachedImages(messages); - const reply = enriched.find((m) => m.id === 'reply')!; - const replyPaths = (reply._attachedFiles ?? []).map((f) => f.filePath); - expect(replyPaths).toContain('/tmp/report.pdf'); - expect(replyPaths.find((p) => p?.endsWith('.png'))).toBeUndefined(); - }); - - it('attaches a gateway-media entry to the assistant-media bubble itself (text reply + injected bubble)', () => { - // Reproduces the exact shape of session - // ~/.openclaw/agents/main/sessions/5fb6925e-...jsonl - // - msg 12: assistant reply "...MEDIA:/tmp/openclaw/desktop_*.png" - // - msg 13: assistant `image` block with flat `url` - // After enrichment, msg 13 must carry exactly one `_attachedFiles` - // entry sourced from the Gateway URL — otherwise ChatMessage's early - // return swallows the bubble (no text, no tools, no extracted images, - // no attachments → returns null → user sees nothing). - const messages: RawMessage[] = [ - { - role: 'user', - id: 'u1', - content: [{ type: 'text', text: 'Send me a desktop screenshot.' }], - }, - { - role: 'assistant', - id: 'reply', - content: [{ type: 'text', text: 'Done, here it is:\n\nMEDIA:/tmp/openclaw/desktop_20260506_193407.png' }], - }, - { - role: 'assistant', - id: 'gateway-media', - content: [{ - type: 'image', - url: '/api/chat/media/outgoing/agent%3Amain%3As-1/e024afb9-2bc2-4a64-bf43-1c26fc779b6b/full', - openUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/e024afb9-2bc2-4a64-bf43-1c26fc779b6b/full', - mimeType: 'image/png', - width: 1536, - height: 998, - alt: 'desktop_20260506_193407.png', - }], - }, - ]; - - const enriched = enrichWithCachedImages(messages); - const reply = enriched.find((m) => m.id === 'reply')!; - expect((reply._attachedFiles ?? []).map((f) => f.filePath)).toEqual([]); - - const bubble = enriched.find((m) => m.id === 'gateway-media')!; - const bubbleEntries = bubble._attachedFiles ?? []; - expect(bubbleEntries).toHaveLength(1); - expect(bubbleEntries[0]).toMatchObject({ - gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/e024afb9-2bc2-4a64-bf43-1c26fc779b6b/full', - mimeType: 'image/png', - source: 'gateway-media', - }); - }); - - it('keeps image-typed MEDIA: refs when there is no Gateway bubble after the reply', () => { - // If the Gateway is disabled / hasn't injected a bubble, the agent's - // own `MEDIA:` marker is the only signal and must still surface. - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'reply', - content: [{ type: 'text', text: 'Here is the screenshot:\n\nMEDIA:/tmp/foo.png' }], - }, - ]; - - const enriched = enrichWithCachedImages(messages); - const reply = enriched[0]!; - const replyPaths = (reply._attachedFiles ?? []).map((f) => f.filePath); - expect(replyPaths).toEqual(['/tmp/foo.png']); - }); - - it('promotes markdown local image paths to attached files', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'reply', - content: [{ - type: 'text', - text: '宇航员图片完成啦 🧑‍🚀✨\n\n![Astronaut with Milky Way in helmet visor](/Users/me/.openclaw/media/tool-image-generation/cat.png)', - }], - }, - ]; - - const enriched = enrichWithCachedImages(messages); - expect(enriched[0]?._attachedFiles?.map((file) => file.filePath)).toEqual([ - '/Users/me/.openclaw/media/tool-image-generation/cat.png', - ]); - }); - - it('promotes markdown gateway image URLs to gateway-media attachments', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'reply', - content: [{ - type: 'text', - text: 'Done\n\n![Astronaut with Milky Way in helmet visor](/api/chat/media/outgoing/agent%3Amain%3As-1/abc/full)', - }], - }, - ]; - - const enriched = enrichWithCachedImages(messages); - expect(enriched[0]?._attachedFiles?.[0]).toMatchObject({ - gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/abc/full', - source: 'gateway-media', - fileName: 'Astronaut with Milky Way in helmet visor', - }); - }); -}); - -describe('enrichWithToolCallAttachments', () => { - it('attaches image paths from message tool attachments array', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'send-image', - content: [{ - type: 'tool_use', - id: 'tool-1', - name: 'message', - input: { - action: 'send', - attachments: [{ filePath: '/Users/me/.openclaw/media/tool-image-generation/cat.png' }], - }, - }], - }, - ]; - - const enriched = enrichWithToolCallAttachments(messages); - expect(enriched[0]?._attachedFiles?.map((file) => file.filePath)).toEqual([ - '/Users/me/.openclaw/media/tool-image-generation/cat.png', - ]); - expect(enriched[0]?._attachedFiles?.[0]?.mimeType).toBe('image/png'); - }); - - it('attaches image paths from message tool mediaUrl arguments', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'send-image', - content: [{ - type: 'tool_use', - id: 'tool-1', - name: 'message', - input: { - action: 'send', - mediaUrl: '/Users/me/.openclaw/media/tool-image-generation/banana.png', - }, - }], - }, - ]; - - const enriched = enrichWithToolCallAttachments(messages); - expect(enriched[0]?._attachedFiles?.map((file) => file.filePath)).toEqual([ - '/Users/me/.openclaw/media/tool-image-generation/banana.png', - ]); - }); -}); - -describe('loadMissingPreviews', () => { - it('retries image preview hydration when Gateway media records are not ready yet', async () => { - vi.useFakeTimers(); - try { - const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full'; - const messages: RawMessage[] = [ - { - role: 'assistant', - content: [], - _attachedFiles: [{ - fileName: 'generated.png', - mimeType: 'image/png', - fileSize: 0, - preview: null, - gatewayUrl, - source: 'gateway-media', - }], - }, - ]; - - thumbnailsMock - .mockResolvedValueOnce({ [gatewayUrl]: { preview: null, fileSize: 0 } }) - .mockResolvedValueOnce({ [gatewayUrl]: { preview: 'data:image/png;base64,ok', fileSize: 42 } }); - - const result = loadMissingPreviews(messages); - expect(thumbnailsMock).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(300); - await expect(result).resolves.toBe(true); - - expect(thumbnailsMock).toHaveBeenCalledTimes(2); - expect(messages[0]?._attachedFiles?.[0]).toMatchObject({ - preview: 'data:image/png;base64,ok', - fileSize: 42, - }); - expect(messages[0]?._attachedFiles?.[0]?.previewStatus).toBeUndefined(); - } finally { - vi.useRealTimers(); - } - }); - - it('marks image previews unavailable after retry exhaustion', async () => { - vi.useFakeTimers(); - try { - const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/missing/full'; - const messages: RawMessage[] = [ - { - role: 'assistant', - content: [], - _attachedFiles: [{ - fileName: 'missing.png', - mimeType: 'image/png', - fileSize: 0, - preview: null, - gatewayUrl, - source: 'gateway-media', - }], - }, - ]; - - thumbnailsMock.mockResolvedValue({ [gatewayUrl]: { preview: null, fileSize: 0 } }); - - const result = loadMissingPreviews(messages); - await vi.advanceTimersByTimeAsync(300 + 900 + 1800); - await expect(result).resolves.toBe(true); - - expect(thumbnailsMock).toHaveBeenCalledTimes(4); - expect(messages[0]?._attachedFiles?.[0]?.previewStatus).toBe('unavailable'); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/tests/unit/chat-helpers-extract-paths.test.ts b/tests/unit/chat-helpers-extract-paths.test.ts deleted file mode 100644 index 31107ba8..00000000 --- a/tests/unit/chat-helpers-extract-paths.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractRawFilePaths } from '@/stores/chat/helpers'; - -describe('extractRawFilePaths', () => { - it('detects bare unix-absolute paths to documents', () => { - const refs = extractRawFilePaths('Saved the report to /tmp/report.pdf for review.'); - expect(refs).toEqual([{ filePath: '/tmp/report.pdf', mimeType: 'application/pdf' }]); - }); - - it('still rejects URLs and path-fragments after a colon', () => { - const refs = extractRawFilePaths('See https://example.com/manual.pdf or relative/path.pdf'); - // The legacy guard still keeps URL bodies and mid-token slashes out. - expect(refs).toEqual([]); - }); - - it('surfaces MEDIA: tagged artifacts emitted by the runtime', () => { - const sample = - 'Here is the spreadsheet:\nMEDIA:/Users/alice/.openclaw/media/outbound/sales-2025---abc123.xlsx'; - const refs = extractRawFilePaths(sample); - expect(refs).toHaveLength(1); - expect(refs[0]).toMatchObject({ - filePath: '/Users/alice/.openclaw/media/outbound/sales-2025---abc123.xlsx', - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }); - }); - - it('handles multiple MEDIA: paths inline', () => { - const sample = '(MEDIA:/a/b/one.pdf) and MEDIA:~/two.xlsx are ready.'; - const refs = extractRawFilePaths(sample); - expect(refs.map((r) => r.filePath)).toEqual([ - '/a/b/one.pdf', - '~/two.xlsx', - ]); - }); - - it('also accepts the lowercase media: prefix', () => { - const refs = extractRawFilePaths('result media:/tmp/out.pdf done.'); - expect(refs).toEqual([ - { filePath: '/tmp/out.pdf', mimeType: 'application/pdf' }, - ]); - }); - - it('captures MEDIA: tagged Windows artifact paths', () => { - const refs = extractRawFilePaths(String.raw`SVG file is ready: -MEDIA:C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`); - - expect(refs).toEqual([ - { - filePath: String.raw`C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`, - mimeType: 'image/svg+xml', - }, - ]); - }); - - it('captures MEDIA: paths that contain ASCII spaces (macOS screenshot default name)', () => { - // Regression: macOS' default screenshot filename is - // "Screenshot YYYY-MM-DD at HH.MM.SS.png" (en locale) or - // "截屏 YYYY-MM-DD HH.MM.SS.png" (zh locale) - // and the agent typically emits it verbatim via `MEDIA:` after - // `ls ~/Desktop`. The previous regex excluded ASCII spaces from the - // captured path, which made the extractor stop at "Screenshot" and - // never reach the `.png`, so the screenshot silently failed to - // surface as an attachment. - const sample = 'Found it on the desktop, sending now:\n\nMEDIA:/Users/alice/Desktop/Screenshot 2026-05-06 at 17.46.51.png'; - const refs = extractRawFilePaths(sample); - expect(refs).toEqual([ - { - filePath: '/Users/alice/Desktop/Screenshot 2026-05-06 at 17.46.51.png', - mimeType: 'image/png', - }, - ]); - }); - - it('captures MEDIA: paths whose filename is non-ASCII (zh-locale macOS screenshot)', () => { - // Companion to the previous case — make sure paths whose filename - // is full-Unicode (the zh-locale macOS default) are also captured. - const sample = 'Sending the screenshot now:\n\nMEDIA:/Users/alice/Desktop/截屏 2026-05-06 17.46.51.png'; - const refs = extractRawFilePaths(sample); - expect(refs).toEqual([ - { - filePath: '/Users/alice/Desktop/截屏 2026-05-06 17.46.51.png', - mimeType: 'image/png', - }, - ]); - }); - - it('captures inline MEDIA: markers glued to caption punctuation', () => { - const sample = - '橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png'; - const refs = extractRawFilePaths(sample); - expect(refs).toEqual([ - { - filePath: '/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png', - mimeType: 'image/png', - }, - ]); - }); - - it('keeps non-MEDIA prose after a space-bearing path out of the captured filename', () => { - // The lookahead must terminate the match at the first non-path character - // (newline, quote, paren, comma, full-stop, ...). Otherwise a long line - // like "MEDIA:/p with spaces.png and then more prose" would gobble the - // trailing narration into the "filename". - const sample = 'MEDIA:/tmp/my shot.png and then more prose'; - const refs = extractRawFilePaths(sample); - expect(refs.map((r) => r.filePath)).toEqual(['/tmp/my shot.png']); - }); - - it('detects OpenClaw skill directories without file extensions', () => { - const refs = extractRawFilePaths('位置: ~/.openclaw/skills/open-eastmoney'); - expect(refs).toEqual([ - { filePath: '~/.openclaw/skills/open-eastmoney', mimeType: 'application/x-directory' }, - ]); - }); - - it('preserves unicode Windows skill directory paths', () => { - const refs = extractRawFilePaths(String.raw`位置: C:\Users\张三\.openclaw\skills\打开东方财富。`); - expect(refs).toEqual([ - { filePath: String.raw`C:\Users\张三\.openclaw\skills\打开东方财富`, mimeType: 'application/x-directory' }, - ]); - }); -}); diff --git a/tests/unit/chat-helpers-lifecycle-predicates.test.ts b/tests/unit/chat-helpers-lifecycle-predicates.test.ts deleted file mode 100644 index c916e582..00000000 --- a/tests/unit/chat-helpers-lifecycle-predicates.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - hasNonToolAssistantContent, - hasPendingToolUse, - isToolOnlyMessage, -} from '@/stores/chat/helpers'; -import type { RawMessage } from '@/stores/chat'; - -/** - * Cross-protocol coverage for the lifecycle predicates that drive whether - * ClawX's UI keeps the Thinking… indicator / Execution Graph "active" or - * tears them down. - * - * In production, ClawX consumes already-normalized messages from the OpenClaw - * Gateway (camelCase, Anthropic-style content blocks). But the helpers are - * written defensively so they also work when: - * - Anthropic Messages API output is passed through unchanged (snake_case, - * `type: "tool_use"`, `stop_reason: "tool_use"`) - * - OpenAI Chat Completions API output is normalized to a message-shaped - * object that retains the top-level `tool_calls` array - * - * This file documents the supported shapes via direct unit tests. If a future - * runtime change starts emitting a different shape (e.g. raw OpenAI Responses - * API `output[].type === "function_call"` items), these tests should be the - * place to add new coverage — and the helpers extended accordingly. - */ - -const ANTHROPIC_INTERMEDIATE_TOOL_USE: RawMessage = { - id: 'anthropic-1', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Let me search for the weather.' }, - { type: 'tool_use', id: 'toolu_01', name: 'web_search', input: { query: 'Kunming' } }, - ], - // Anthropic native: snake_case at message top level - stop_reason: 'tool_use', -}; - -const ANTHROPIC_FINAL_TEXT: RawMessage = { - id: 'anthropic-2', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'I have all I need.' }, - { type: 'text', text: 'The weather in Kunming is mild.' }, - ], - stop_reason: 'end_turn', -}; - -const GATEWAY_NORMALIZED_INTERMEDIATE: RawMessage = { - id: 'gateway-1', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Need to call a tool.' }, - { type: 'toolCall', id: 'tc1', name: 'web_search', input: { query: 'foo' } }, - ], - // Gateway-normalized: camelCase + verb-cased stop reason - stopReason: 'toolUse', -}; - -const GATEWAY_NORMALIZED_FINAL: RawMessage = { - id: 'gateway-2', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Done thinking.' }, - { type: 'text', text: 'Here is the answer.' }, - ], - stopReason: 'stop', -}; - -const GATEWAY_MIXED_PENDING_TOOL: RawMessage = { - id: 'gateway-3', - role: 'assistant', - // Mixed [thinking, text, toolCall] with stopReason=toolUse — observed in - // some MiniMax / gpt-5.5 variants. The text is real user-visible output - // but the run is still pending a tool result. - content: [ - { type: 'thinking', thinking: 'I should search first.' }, - { type: 'text', text: 'Let me search for that.' }, - { type: 'toolCall', id: 'tc2', name: 'web_search', input: { query: 'foo' } }, - ], - stopReason: 'toolUse', -}; - -const OPENAI_CC_INTERMEDIATE_TOOL_CALL: RawMessage = { - id: 'openai-cc-1', - role: 'assistant', - // OpenAI Chat Completions: `content` is a string (often empty when tools - // are called) and the tool-call signal lives in a top-level `tool_calls` - // array. There's no message-level `stop_reason`; the choice-level - // `finish_reason: "tool_calls"` doesn't survive on the message itself. - content: '', - tool_calls: [ - { - id: 'call_abc123', - type: 'function', - function: { name: 'web_search', arguments: '{"query":"foo"}' }, - }, - ], -}; - -const OPENAI_CC_FINAL_TEXT: RawMessage = { - id: 'openai-cc-2', - role: 'assistant', - content: 'Here is the final answer.', -}; - -const OPENAI_CC_TOOLCALLS_CAMELCASE: RawMessage = { - id: 'openai-cc-3', - role: 'assistant', - content: '', - // Some adapters camelCase the field as `toolCalls`. The helper accepts both. - toolCalls: [ - { - id: 'call_xyz', - type: 'function', - function: { name: 'web_search', arguments: '{}' }, - }, - ], -}; - -const PLAIN_USER: RawMessage = { - id: 'user-1', - role: 'user', - content: 'hello', -}; - -describe('hasPendingToolUse', () => { - it('detects Anthropic-native intermediate (stop_reason=tool_use + tool_use block)', () => { - expect(hasPendingToolUse(ANTHROPIC_INTERMEDIATE_TOOL_USE)).toBe(true); - }); - - it('detects Gateway-normalized intermediate (stopReason=toolUse + toolCall block)', () => { - expect(hasPendingToolUse(GATEWAY_NORMALIZED_INTERMEDIATE)).toBe(true); - }); - - it('detects mixed [thinking, text, toolCall] with stopReason=toolUse', () => { - expect(hasPendingToolUse(GATEWAY_MIXED_PENDING_TOOL)).toBe(true); - }); - - it('detects OpenAI Chat Completions intermediate via tool_calls array', () => { - expect(hasPendingToolUse(OPENAI_CC_INTERMEDIATE_TOOL_CALL)).toBe(true); - }); - - it('detects OpenAI Chat Completions intermediate via toolCalls (camelCase) array', () => { - expect(hasPendingToolUse(OPENAI_CC_TOOLCALLS_CAMELCASE)).toBe(true); - }); - - it('returns false for Anthropic-native final reply (end_turn + text)', () => { - expect(hasPendingToolUse(ANTHROPIC_FINAL_TEXT)).toBe(false); - }); - - it('returns false for Gateway-normalized final reply (stopReason=stop + text)', () => { - expect(hasPendingToolUse(GATEWAY_NORMALIZED_FINAL)).toBe(false); - }); - - it('returns false for OpenAI Chat Completions plain-text reply', () => { - expect(hasPendingToolUse(OPENAI_CC_FINAL_TEXT)).toBe(false); - }); - - it('returns false for user messages', () => { - expect(hasPendingToolUse(PLAIN_USER)).toBe(false); - }); - - it('returns false for undefined', () => { - expect(hasPendingToolUse(undefined)).toBe(false); - }); -}); - -describe('hasNonToolAssistantContent', () => { - // CRITICAL invariant: thinking blocks are NEVER counted as user-visible - // output. Treating thinking as content was the root cause of the - // "Thinking… disappears mid-tool-chain" bug. - it('does NOT count thinking-only messages as user-visible content (Anthropic shape)', () => { - const msg: RawMessage = { - id: 'x', - role: 'assistant', - content: [{ type: 'thinking', thinking: 'Some private reasoning…' }], - }; - expect(hasNonToolAssistantContent(msg)).toBe(false); - }); - - it('does NOT count [thinking, tool_use] as user-visible content (Anthropic intermediate)', () => { - expect(hasNonToolAssistantContent(ANTHROPIC_INTERMEDIATE_TOOL_USE)).toBe(false); - }); - - it('does NOT count [thinking, toolCall] as user-visible content (Gateway intermediate)', () => { - expect(hasNonToolAssistantContent(GATEWAY_NORMALIZED_INTERMEDIATE)).toBe(false); - }); - - it('counts text blocks as user-visible (Anthropic final)', () => { - expect(hasNonToolAssistantContent(ANTHROPIC_FINAL_TEXT)).toBe(true); - }); - - it('counts text blocks as user-visible (Gateway final)', () => { - expect(hasNonToolAssistantContent(GATEWAY_NORMALIZED_FINAL)).toBe(true); - }); - - it('counts string content as user-visible (OpenAI ChatCompletions final)', () => { - expect(hasNonToolAssistantContent(OPENAI_CC_FINAL_TEXT)).toBe(true); - }); - - it('does NOT count empty string content (OpenAI ChatCompletions intermediate)', () => { - expect(hasNonToolAssistantContent(OPENAI_CC_INTERMEDIATE_TOOL_CALL)).toBe(false); - }); - - it('counts image blocks as user-visible', () => { - const msg: RawMessage = { - id: 'img', - role: 'assistant', - content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }], - }; - expect(hasNonToolAssistantContent(msg)).toBe(true); - }); -}); - -describe('isToolOnlyMessage', () => { - it('flags Anthropic [thinking, tool_use] as tool-only', () => { - expect(isToolOnlyMessage(ANTHROPIC_INTERMEDIATE_TOOL_USE)).toBe(true); - }); - - it('flags Gateway [thinking, toolCall] as tool-only', () => { - expect(isToolOnlyMessage(GATEWAY_NORMALIZED_INTERMEDIATE)).toBe(true); - }); - - it('flags OpenAI ChatCompletions empty-content + tool_calls as tool-only', () => { - expect(isToolOnlyMessage(OPENAI_CC_INTERMEDIATE_TOOL_CALL)).toBe(true); - }); - - it('does NOT flag mixed [thinking, text, toolCall] as tool-only (text present)', () => { - // For mixed messages with real text output, isToolOnlyMessage alone is - // insufficient — this is exactly why the lifecycle code uses - // `isToolOnlyMessage(msg) || hasPendingToolUse(msg)`. - expect(isToolOnlyMessage(GATEWAY_MIXED_PENDING_TOOL)).toBe(false); - expect(hasPendingToolUse(GATEWAY_MIXED_PENDING_TOOL)).toBe(true); - }); - - it('does NOT flag a final text reply as tool-only', () => { - expect(isToolOnlyMessage(GATEWAY_NORMALIZED_FINAL)).toBe(false); - expect(isToolOnlyMessage(ANTHROPIC_FINAL_TEXT)).toBe(false); - expect(isToolOnlyMessage(OPENAI_CC_FINAL_TEXT)).toBe(false); - }); -}); - -/** - * Composite assertion: the trio `isToolOnlyMessage(msg) || hasPendingToolUse(msg)` - * is the actual gate used by `applyLoadedMessages` and the runtime `final` - * handler. This block proves that gate behaves consistently across all three - * provider protocols ClawX may encounter. - */ -describe('lifecycle gate (isToolOnlyMessage || hasPendingToolUse)', () => { - const gate = (msg: RawMessage) => isToolOnlyMessage(msg) || hasPendingToolUse(msg); - - it.each([ - ['Anthropic intermediate', ANTHROPIC_INTERMEDIATE_TOOL_USE, true], - ['Gateway intermediate', GATEWAY_NORMALIZED_INTERMEDIATE, true], - ['Gateway mixed [thinking,text,toolCall]', GATEWAY_MIXED_PENDING_TOOL, true], - ['OpenAI CC intermediate (tool_calls)', OPENAI_CC_INTERMEDIATE_TOOL_CALL, true], - ['OpenAI CC intermediate (toolCalls camelCase)', OPENAI_CC_TOOLCALLS_CAMELCASE, true], - ['Anthropic final text (end_turn)', ANTHROPIC_FINAL_TEXT, false], - ['Gateway final text (stop)', GATEWAY_NORMALIZED_FINAL, false], - ['OpenAI CC final text', OPENAI_CC_FINAL_TEXT, false], - ])('classifies %s as intermediate=%j', (_label, msg, expected) => { - expect(gate(msg)).toBe(expected); - }); -}); diff --git a/tests/unit/chat-history-actions.test.ts b/tests/unit/chat-history-actions.test.ts deleted file mode 100644 index 37940060..00000000 --- a/tests/unit/chat-history-actions.test.ts +++ /dev/null @@ -1,907 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { chatHistoryRpcParams } from './gateway-rpc-test-utils'; - -const gatewayRpcMock = vi.fn(); -const hostApiFetchMock = vi.fn(); -const gatewayStoreGetStateMock = vi.fn(); -const clearHistoryPoll = vi.fn(); -const enrichWithCachedImages = vi.fn((messages) => messages); -const enrichWithToolResultFiles = vi.fn((messages) => messages); -const enrichWithToolCallAttachments = vi.fn((messages) => messages); -const getMessageErrorMessage = vi.fn((message: { errorMessage?: string; error_message?: string } | undefined) => { - if (!message) return null; - return message.errorMessage ?? message.error_message ?? null; -}); -const getMessageStopReason = vi.fn((message: { stopReason?: string; stop_reason?: string } | undefined) => { - if (!message) return null; - return message.stopReason ?? message.stop_reason ?? null; -}); -const getMessageText = vi.fn((content: unknown) => typeof content === 'string' ? content : ''); -const hasNonToolAssistantContent = vi.fn((message: { content?: unknown } | undefined) => { - if (!message) return false; - return typeof message.content === 'string' ? message.content.trim().length > 0 : true; -}); -const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult' || role === 'tool_result'); -const isInternalMessage = vi.fn((msg: { role?: unknown; content?: unknown }) => { - if (msg.role === 'system') return true; - if (msg.role === 'assistant') { - const text = typeof msg.content === 'string' ? msg.content : ''; - if (/^(HEARTBEAT_OK|NO_REPLY)\s*$/.test(text)) return true; - } - return false; -}); -const messageHasToolUse = (msg: { role?: unknown; content?: unknown; tool_calls?: unknown; toolCalls?: unknown }) => { - if (msg.role !== 'assistant') return false; - if (Array.isArray(msg.content)) { - return (msg.content as Array<{ type?: string }>).some( - (block) => block.type === 'tool_use' || block.type === 'toolCall', - ); - } - const toolCalls = (msg as { tool_calls?: unknown; toolCalls?: unknown }).tool_calls - ?? (msg as { toolCalls?: unknown }).toolCalls; - return Array.isArray(toolCalls) && toolCalls.length > 0; -}; -const shouldDropMessageFromHistory = vi.fn((msg: { role?: unknown; content?: unknown; tool_calls?: unknown; toolCalls?: unknown }) => { - if (isToolResultRole(msg.role)) return true; - if (messageHasToolUse(msg)) return false; - return isInternalMessage(msg); -}); -const setLastChatEventAt = vi.fn(); -const loadMissingPreviews = vi.fn(async () => false); -const toMs = vi.fn((ts: number) => ts < 1e12 ? ts * 1000 : ts); - -vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), - hostApi: { - gateway: { - rpc: async (method: string, params?: unknown, timeoutMs?: number) => { - const result = await gatewayRpcMock(method, params, timeoutMs) as { - success?: boolean; - result?: unknown; - error?: string; - }; - if (result?.success === false) { - throw new Error(result.error || `RPC ${method} failed`); - } - return result?.result; - }, - }, - cron: { - sessionHistory: async (input: { sessionKey: string; limit?: number }) => { - const params = new URLSearchParams({ sessionKey: input.sessionKey }); - if (input.limit != null) params.set('limit', String(input.limit)); - return hostApiFetchMock(`/api/cron/session-history?${params.toString()}`); - }, - }, - }, -})); - -vi.mock('@/stores/gateway', () => ({ - useGatewayStore: { - getState: () => gatewayStoreGetStateMock(), - }, -})); - -vi.mock('@/stores/chat/helpers', () => ({ - clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args), - enrichWithCachedImages: (...args: unknown[]) => enrichWithCachedImages(...args), - enrichWithToolResultFiles: (...args: unknown[]) => enrichWithToolResultFiles(...args), - enrichWithToolCallAttachments: (...args: unknown[]) => enrichWithToolCallAttachments(...args), - getLatestOptimisticUserMessage: (messages: Array<{ role: string; timestamp?: number }>, userTimestampMs: number) => - [...messages].reverse().find( - (message) => message.role === 'user' - && (!message.timestamp || Math.abs(toMs(message.timestamp) - userTimestampMs) < 120_000), - ), - getMessageText: (...args: unknown[]) => getMessageText(...args), - hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args), - hasAssistantAfterLastRealUser: (messages: Array<{ role?: string }>) => - messages.some((message) => message.role === 'assistant'), - isInternalMessage: (...args: unknown[]) => isInternalMessage(...args), - shouldDropMessageFromHistory: (...args: unknown[]) => shouldDropMessageFromHistory(...args), - isToolResultRole: (...args: unknown[]) => isToolResultRole(...args), - loadMissingPreviews: (...args: unknown[]) => loadMissingPreviews(...args), - setLastChatEventAt: (...args: unknown[]) => setLastChatEventAt(...args), - mergePendingOptimisticUserMessages: (_sessionKey: string, messages: unknown[]) => messages, - hasOptimisticServerEcho: ( - loadedMessages: Array<{ role: string; timestamp?: number; content?: unknown; _attachedFiles?: Array<{ filePath?: string; fileName?: string; mimeType?: string; fileSize?: number }> }>, - optimistic: { role: string; timestamp?: number; content?: unknown; _attachedFiles?: Array<{ filePath?: string; fileName?: string; mimeType?: string; fileSize?: number }> }, - optimisticTimestampMs: number, - ) => loadedMessages.some((candidate) => { - if (candidate.role !== 'user') return false; - const normalizeText = (content: unknown) => (typeof content === 'string' ? content : '') - .replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '') - .replace(/\s+/g, ' ') - .trim(); - const candidateText = normalizeText(candidate.content); - const optimisticText = normalizeText(optimistic.content); - const candidateAttachments = (candidate._attachedFiles || []).map((file) => file.filePath || `${file.fileName}|${file.mimeType}|${file.fileSize}`).sort().join('::'); - const optimisticAttachments = (optimistic._attachedFiles || []).map((file) => file.filePath || `${file.fileName}|${file.mimeType}|${file.fileSize}`).sort().join('::'); - const hasCandidateTimestamp = candidate.timestamp != null; - const timestampMatches = hasCandidateTimestamp - ? Math.abs(toMs(candidate.timestamp as number) - optimisticTimestampMs) < 120_000 - : false; - - if (candidateText && optimisticText && candidateText === optimisticText && candidateAttachments === optimisticAttachments) return true; - if (candidateText && optimisticText && candidateText === optimisticText && (!hasCandidateTimestamp || timestampMatches)) return true; - if (candidateAttachments && optimisticAttachments && candidateAttachments === optimisticAttachments && (!hasCandidateTimestamp || timestampMatches)) return true; - return false; - }), - dropRedundantOptimisticUserMessages: (_sessionKey: string, messages: unknown[]) => messages, - isRecoverableRuntimeError: (message: string) => /\bterminated\b/i.test(message), - matchesOptimisticUserMessage: ( - candidate: { role: string; timestamp?: number; content?: unknown; _attachedFiles?: Array<{ filePath?: string; fileName?: string; mimeType?: string; fileSize?: number }> }, - optimistic: { role: string; timestamp?: number; content?: unknown; _attachedFiles?: Array<{ filePath?: string; fileName?: string; mimeType?: string; fileSize?: number }> }, - optimisticTimestampMs: number, - ) => { - if (candidate.role !== 'user') return false; - const normalizeText = (content: unknown) => (typeof content === 'string' ? content : '') - .replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '') - .replace(/\s+/g, ' ') - .trim(); - const candidateText = normalizeText(candidate.content); - const optimisticText = normalizeText(optimistic.content); - const candidateAttachments = (candidate._attachedFiles || []).map((file) => file.filePath || `${file.fileName}|${file.mimeType}|${file.fileSize}`).sort().join('::'); - const optimisticAttachments = (optimistic._attachedFiles || []).map((file) => file.filePath || `${file.fileName}|${file.mimeType}|${file.fileSize}`).sort().join('::'); - const hasCandidateTimestamp = candidate.timestamp != null; - const timestampMatches = hasCandidateTimestamp - ? Math.abs(toMs(candidate.timestamp as number) - optimisticTimestampMs) < 120_000 - : false; - - if (candidateText && optimisticText && candidateText === optimisticText && candidateAttachments === optimisticAttachments) return true; - if (candidateText && optimisticText && candidateText === optimisticText && (!hasCandidateTimestamp || timestampMatches)) return true; - if (candidateAttachments && optimisticAttachments && candidateAttachments === optimisticAttachments && (!hasCandidateTimestamp || timestampMatches)) return true; - return false; - }, - getMessageErrorMessage: (...args: unknown[]) => getMessageErrorMessage(...args), - getMessageStopReason: (...args: unknown[]) => getMessageStopReason(...args), - shouldShowRunError: ( - sessionKey: string, - errorMessage: string | null | undefined, - dismissedBySession: Record, - ) => { - if (!errorMessage) return null; - if (dismissedBySession[sessionKey] === errorMessage) return null; - return errorMessage; - }, - toMs: (...args: unknown[]) => toMs(...args as Parameters), -})); - -type ChatLikeState = { - currentSessionKey: string; - messages: Array<{ role: string; timestamp?: number; content?: unknown; _attachedFiles?: unknown[] }>; - loading: boolean; - error: string | null; - runError: string | null; - dismissedRunErrors: Record; - sending: boolean; - lastUserMessageAt: number | null; - pendingFinal: boolean; - sessionLabels: Record; - sessionLastActivity: Record; - thinkingLevel: string | null; - activeRunId: string | null; -}; - -function makeHarness(initial?: Partial) { - let state: ChatLikeState = { - currentSessionKey: 'agent:main:main', - messages: [], - loading: false, - error: null, - runError: null, - dismissedRunErrors: {}, - sending: false, - lastUserMessageAt: null, - pendingFinal: false, - sessionLabels: {}, - sessionLastActivity: {}, - thinkingLevel: null, - activeRunId: null, - ...initial, - }; - - const set = (partial: Partial | ((s: ChatLikeState) => Partial)) => { - const patch = typeof partial === 'function' ? partial(state) : partial; - state = { ...state, ...patch }; - }; - const get = () => state; - return { set, get, read: () => state }; -} - -describe('chat history actions', () => { - beforeEach(async () => { - vi.resetAllMocks(); - vi.resetModules(); - vi.useRealTimers(); - gatewayRpcMock.mockResolvedValue({ success: true, result: { messages: [] } }); - hostApiFetchMock.mockResolvedValue({ messages: [] }); - const { resetChatHistoryMaxCharsCache, resolveChatHistoryMaxChars } = await import('@/stores/chat/history-rpc-params'); - resetChatHistoryMaxCharsCache(); - await resolveChatHistoryMaxChars(); - gatewayStoreGetStateMock.mockReturnValue({ - status: { state: 'running', port: 18789, connectedAt: Date.now() }, - }); - }); - - it('uses cron session fallback when gateway history is empty', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:cron:job-1', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - hostApiFetchMock.mockResolvedValueOnce({ - messages: [ - { - id: 'cron-meta-job-1', - role: 'system', - content: 'Scheduled task: Drink water', - timestamp: 1773281731495, - }, - { - id: 'cron-run-1', - role: 'assistant', - content: 'Drink water 💧', - timestamp: 1773281732751, - }, - ], - }); - - await actions.loadHistory(); - - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/cron/session-history?sessionKey=agent%3Amain%3Acron%3Ajob-1&limit=200', - ); - expect(h.read().messages.map((message) => message.content)).toEqual([ - 'Drink water 💧', - ]); - expect(h.read().sessionLastActivity['agent:main:cron:job-1']).toBe(1773281732751); - expect(h.read().loading).toBe(false); - }); - - it('does not use cron fallback for normal sessions', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - await actions.loadHistory(); - - expect(hostApiFetchMock).not.toHaveBeenCalled(); - expect(h.read().messages).toEqual([]); - expect(h.read().loading).toBe(false); - }); - - it('preserves existing messages when history refresh fails for the current session', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - messages: [ - { - role: 'assistant', - content: 'still here', - timestamp: 1773281732, - }, - ], - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockRejectedValueOnce(new Error('Gateway unavailable')); - - await actions.loadHistory(); - - expect(h.read().messages.map((message) => message.content)).toEqual(['still here']); - expect(h.read().error).toBe('Gateway unavailable'); - expect(h.read().loading).toBe(false); - }); - - it('finalizes sending and surfaces the latest terminal assistant error from history', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - sending: true, - activeRunId: 'run-error', - pendingFinal: true, - lastUserMessageAt: 1773281731000, - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'What model are you?', timestamp: 1773281731 }, - { - role: 'assistant', - content: [], - timestamp: 1773281732, - stopReason: 'error', - errorMessage: '404 Resource not found', - }, - ], - }, - }); - - await actions.loadHistory(true); - - expect(clearHistoryPoll).toHaveBeenCalledTimes(1); - expect(h.read().runError).toBe('404 Resource not found'); - expect(h.read().sending).toBe(false); - expect(h.read().pendingFinal).toBe(false); - expect(h.read().activeRunId).toBeNull(); - expect(h.read().lastUserMessageAt).toBeNull(); - }); - - it('does not surface an earlier assistant error when a later assistant reply succeeded', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - runError: 'Connection error.', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'First question', timestamp: 1000 }, - { role: 'assistant', content: [], timestamp: 1001, stopReason: 'error', errorMessage: 'Connection error.' }, - { role: 'user', content: 'Second question', timestamp: 1002 }, - { role: 'assistant', content: 'Recovered answer', timestamp: 1003 }, - ], - }, - }); - - await actions.loadHistory(true); - - expect(h.read().runError).toBeNull(); - expect(h.read().messages.map((message) => message.content)).toEqual([ - 'First question', - [], - 'Second question', - 'Recovered answer', - ]); - }); - - it('clears stale runError when refreshed history no longer contains a terminal assistant error', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - runError: 'old model error', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'What model are you?', timestamp: 1773281731 }, - { role: 'assistant', content: 'I am MiniMax-M2.7', timestamp: 1773281732 }, - ], - }, - }); - - await actions.loadHistory(true); - - expect(h.read().runError).toBeNull(); - expect(h.read().messages.map((message) => message.content)).toEqual([ - 'What model are you?', - 'I am MiniMax-M2.7', - ]); - }); - - it('keeps dismissed runError hidden when history is reloaded', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - dismissedRunErrors: { 'agent:main:main': '404 Resource not found' }, - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'Check semiconductor chatter', timestamp: 1001 }, - { - role: 'assistant', - content: [], - stopReason: 'error', - errorMessage: '404 Resource not found', - timestamp: 1002, - }, - ], - }, - }); - - await actions.loadHistory(true); - - expect(h.read().runError).toBeNull(); - }); - - it('does not set runError from an older assistant failure when a later turn succeeded', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - runError: 'stale', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'first', timestamp: 1773281730 }, - { - role: 'assistant', - content: 'fail', - timestamp: 1773281731, - stopReason: 'error', - errorMessage: '400 bad model', - }, - { role: 'user', content: 'retry', timestamp: 1773281732 }, - { role: 'assistant', content: 'ok now', timestamp: 1773281733 }, - ], - }, - }); - - await actions.loadHistory(true); - - expect(h.read().runError).toBeNull(); - }); - - it('retries the first foreground startup history load after a timeout and then succeeds', async () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - gatewayStoreGetStateMock.mockReturnValue({ - status: { state: 'running', port: 18789, connectedAt: Date.now() - 40_000 }, - }); - - gatewayRpcMock - .mockResolvedValueOnce({ success: false, error: 'RPC timeout: chat.history' }) - .mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'assistant', content: 'restored after retry', timestamp: 1000 }, - ], - }, - }); - - const loadPromise = actions.loadHistory(); - await vi.runAllTimersAsync(); - await loadPromise; - - expect(gatewayRpcMock).toHaveBeenNthCalledWith( - 1, - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - 35_000, - ); - expect(gatewayRpcMock).toHaveBeenNthCalledWith( - 2, - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - 35_000, - ); - expect(h.read().messages.map((message) => message.content)).toEqual(['restored after retry']); - expect(h.read().error).toBeNull(); - expect(warnSpy).toHaveBeenCalledWith( - '[chat.history] startup retry scheduled', - expect.objectContaining({ - sessionKey: 'agent:main:main', - attempt: 1, - errorKind: 'timeout', - }), - ); - warnSpy.mockRestore(); - }); - - it('stops retrying once the load no longer belongs to the active session', async () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockImplementationOnce(async () => { - h.set({ - currentSessionKey: 'agent:main:other', - loading: false, - messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }], - }); - return { success: false, error: 'RPC timeout: chat.history' }; - }); - - await actions.loadHistory(); - - expect(gatewayRpcMock).toHaveBeenCalledTimes(1); - expect(h.read().currentSessionKey).toBe('agent:main:other'); - expect(h.read().messages.map((message) => message.content)).toEqual(['other session']); - expect(h.read().error).toBeNull(); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - it('surfaces a final error only after startup retry budget is exhausted', async () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValue({ - success: false, - error: 'RPC timeout: chat.history', - }); - - const loadPromise = actions.loadHistory(); - await vi.runAllTimersAsync(); - await loadPromise; - - expect(gatewayRpcMock).toHaveBeenCalledTimes(5); - expect(h.read().messages).toEqual([]); - expect(h.read().error).toBe('RPC timeout: chat.history'); - expect(warnSpy).toHaveBeenCalledWith( - '[chat.history] startup retry exhausted', - expect.objectContaining({ - sessionKey: 'agent:main:main', - }), - ); - warnSpy.mockRestore(); - }); - - it('does not retry quiet history refreshes', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValue({ - success: false, - error: 'RPC timeout: chat.history', - }); - - await actions.loadHistory(true); - - expect(gatewayRpcMock).toHaveBeenCalledTimes(1); - expect(h.read().error).toBeNull(); - }); - - it('does not retry non-retryable startup failures', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValue({ - success: false, - error: 'Validation failed: bad session key', - }); - - await actions.loadHistory(); - - expect(gatewayRpcMock).toHaveBeenCalledTimes(1); - expect(h.read().error).toBe('Validation failed: bad session key'); - }); - - it('filters out system messages from loaded history', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness(); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'Hello', timestamp: 1000 }, - { role: 'system', content: 'Gateway restarted', timestamp: 1001 }, - { role: 'assistant', content: 'Hi there!', timestamp: 1002 }, - ], - }, - }); - - await actions.loadHistory(); - - expect(h.read().messages.map((m) => m.content)).toEqual([ - 'Hello', - 'Hi there!', - ]); - }); - - it('filters out HEARTBEAT_OK assistant messages', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness(); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'Hello', timestamp: 1000 }, - { role: 'assistant', content: 'HEARTBEAT_OK', timestamp: 1001 }, - { role: 'assistant', content: 'Real response', timestamp: 1002 }, - ], - }, - }); - - await actions.loadHistory(); - - expect(h.read().messages.map((m) => m.content)).toEqual([ - 'Hello', - 'Real response', - ]); - }); - - it('filters out NO_REPLY assistant messages', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness(); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'Hello', timestamp: 1000 }, - { role: 'assistant', content: 'NO_REPLY', timestamp: 1001 }, - { role: 'assistant', content: 'Actual answer', timestamp: 1002 }, - ], - }, - }); - - await actions.loadHistory(); - - expect(h.read().messages.map((m) => m.content)).toEqual([ - 'Hello', - 'Actual answer', - ]); - }); - - it('keeps normal assistant messages that contain HEARTBEAT_OK as substring', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness(); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { role: 'user', content: 'What is HEARTBEAT_OK?', timestamp: 1000 }, - { role: 'assistant', content: 'HEARTBEAT_OK is a status code', timestamp: 1001 }, - ], - }, - }); - - await actions.loadHistory(); - - expect(h.read().messages.map((m) => m.content)).toEqual([ - 'What is HEARTBEAT_OK?', - 'HEARTBEAT_OK is a status code', - ]); - }); - - it('drops stale history results after the user switches sessions', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - let resolveHistory: ((value: unknown) => void) | null = null; - gatewayRpcMock.mockImplementationOnce(() => new Promise((resolve) => { - resolveHistory = resolve; - })); - - const h = makeHarness({ - currentSessionKey: 'agent:main:session-a', - messages: [ - { - role: 'assistant', - content: 'session b content', - timestamp: 1773281732, - }, - ], - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - const loadPromise = actions.loadHistory(); - h.set({ - currentSessionKey: 'agent:main:session-b', - messages: [ - { - role: 'assistant', - content: 'session b content', - timestamp: 1773281733, - }, - ], - }); - resolveHistory?.({ - success: true, - result: { - messages: [ - { - role: 'assistant', - content: 'stale session a content', - timestamp: 1773281734, - }, - ], - }, - }); - - await loadPromise; - - expect(h.read().currentSessionKey).toBe('agent:main:session-b'); - expect(h.read().messages.map((message) => message.content)).toEqual(['session b content']); - }); - - it('preserves newer same-session messages when preview hydration finishes later', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - let releasePreviewHydration: (() => void) | null = null; - loadMissingPreviews.mockImplementationOnce(async (messages) => { - await new Promise((resolve) => { - releasePreviewHydration = () => { - messages[0]!._attachedFiles = [ - { - fileName: 'image.png', - mimeType: 'image/png', - fileSize: 42, - preview: 'data:image/png;base64,abc', - filePath: '/tmp/image.png', - }, - ]; - resolve(); - }; - }); - return true; - }); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { - id: 'history-1', - role: 'assistant', - content: 'older message', - timestamp: 1000, - }, - ], - }, - }); - - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - await actions.loadHistory(); - - h.set((state) => ({ - messages: [ - ...state.messages, - { - id: 'newer-1', - role: 'assistant', - content: 'newer message', - timestamp: 1001, - }, - ], - })); - - releasePreviewHydration?.(); - await Promise.resolve(); - - expect(h.read().messages.map((message) => message.content)).toEqual([ - 'older message', - 'newer message', - ]); - expect(h.read().messages[0]?._attachedFiles?.[0]?.preview).toBe('data:image/png;base64,abc'); - }); - - it('preserves existing image previews when quiet history refresh rebuilds attachments', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full'; - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - messages: [ - { - id: 'generated-image', - role: 'assistant', - content: 'Generated image', - timestamp: 1000, - _attachedFiles: [ - { - fileName: 'generated.png', - mimeType: 'image/png', - fileSize: 42, - preview: 'data:image/png;base64,already-loaded', - gatewayUrl, - source: 'gateway-media', - }, - ], - }, - ], - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { - id: 'generated-image', - role: 'assistant', - content: 'Generated image', - timestamp: 1000, - _attachedFiles: [ - { - fileName: 'generated.png', - mimeType: 'image/png', - fileSize: 0, - preview: null, - gatewayUrl, - source: 'gateway-media', - }, - ], - }, - ], - }, - }); - - await actions.loadHistory(true); - - expect(h.read().messages[0]?._attachedFiles?.[0]).toMatchObject({ - fileSize: 42, - preview: 'data:image/png;base64,already-loaded', - gatewayUrl, - }); - }); - - it('does not append an optimistic duplicate when history already includes the user message without timestamp', async () => { - const { createHistoryActions } = await import('@/stores/chat/history-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - sending: true, - lastUserMessageAt: 1_773_281_732_000, - messages: [ - { - role: 'user', - content: '[Fri 2026-03-13 10:00 GMT+8] Open browser, search for tech news, and take a screenshot', - timestamp: 1_773_281_732, - }, - ], - }); - const actions = createHistoryActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - messages: [ - { - role: 'user', - content: 'Open browser, search for tech news, and take a screenshot', - }, - { - role: 'assistant', - content: 'Processing', - timestamp: 1_773_281_733, - }, - ], - }, - }); - - await actions.loadHistory(true); - - expect(h.read().messages.map((message) => message.content)).toEqual([ - 'Open browser, search for tech news, and take a screenshot', - 'Processing', - ]); - }); -}); diff --git a/tests/unit/chat-history-reply-while-sending.test.tsx b/tests/unit/chat-history-reply-while-sending.test.tsx index ac445dec..0cef917f 100644 --- a/tests/unit/chat-history-reply-while-sending.test.tsx +++ b/tests/unit/chat-history-reply-while-sending.test.tsx @@ -44,8 +44,6 @@ const { acpState, agentsState, artifactPanelState, chatState, settingsState } = sessions: [{ key: 'agent:main:main', workspacePath: '/workspace' }], currentSessionKey: 'agent:main:main', currentAgentId: 'main', - loading: false, - refresh: vi.fn(), loadSessions: vi.fn().mockResolvedValue(undefined), selectAcpSession: vi.fn(), acknowledgeAcpSessionCreated: vi.fn(), @@ -84,15 +82,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, params?: Record | string) => { if (typeof params === 'string') return params; - if (key === 'executionGraph.collapsedSummary') { - return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim(); - } - if (key === 'executionGraph.agentRun') return 'Main execution'; - if (key === 'executionGraph.title') return 'Execution Graph'; - if (key === 'executionGraph.collapseAction') return 'Collapse'; - if (key === 'executionGraph.thinkingLabel') return 'Thinking'; if (key === 'welcome.subtitle') return 'What can I do for you?'; - if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key; return key; }, }), diff --git a/tests/unit/chat-internal-message-filter.test.ts b/tests/unit/chat-internal-message-filter.test.ts deleted file mode 100644 index cfb14aef..00000000 --- a/tests/unit/chat-internal-message-filter.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { isInternalMessage, shouldDropMessageFromHistory } from '@/stores/chat/helpers'; - -describe('chat internal message filter', () => { - it('filters runtime system injection bundle like async exec completion payload', () => { - const content = [ - 'System (untrusted): [2026-04-22 10:06:24 GMT+8] Exec completed (nimbler, code 0) ...', - 'An async command you ran earlier has completed. The result is shown in the system messages above. Handle the result internally. Do not relay it to the user unless explicitly requested.', - 'Current time: Wednesday, April 22nd, 2026 - 10:06 (Asia/Shanghai) / 2026-04-22 02:06 UTC', - ].join('\n\n'); - - expect(isInternalMessage({ role: 'user', content })).toBe(true); - }); - - it('filters standalone current-time runtime ping', () => { - const content = 'Current time: Wednesday, April 22nd, 2026 - 10:06 (Asia/Shanghai) / 2026-04-22 02:06 UTC'; - - expect(isInternalMessage({ role: 'assistant', content })).toBe(true); - }); - - it('does not filter normal user message that starts with current time', () => { - const content = 'Current time: 北京现在几点?'; - - expect(isInternalMessage({ role: 'user', content })).toBe(false); - }); - - it('does not filter normal assistant text that mentions async completion phrase only', () => { - const content = 'The sentence "An async command you ran earlier has completed" is just an example in docs.'; - - expect(isInternalMessage({ role: 'assistant', content })).toBe(false); - }); - - it('filters OpenClaw heartbeat poll user turns', () => { - expect(isInternalMessage({ role: 'user', content: '[OpenClaw heartbeat poll]' })).toBe(true); - }); - - it('filters inter-session routing payloads', () => { - expect(isInternalMessage({ - role: 'user', - content: '[Inter-session message] sourceSession=image_generate:abc sourceTool=image_generate', - })).toBe(true); - }); - - it('filters OpenClaw runtime continuation prompts', () => { - expect(isInternalMessage({ role: 'user', content: 'Continue the OpenClaw runtime event.' })).toBe(true); - }); - - it('filters runtime continuation prompts stored on msg.text', () => { - expect(isInternalMessage({ - role: 'user', - content: [], - text: 'Continue the OpenClaw runtime event.', - })).toBe(true); - }); - - it('filters interim image-generation status narration', () => { - expect(isInternalMessage({ - role: 'assistant', - content: '生成中,稍等 👨‍🚀', - })).toBe(true); - }); - - it('keeps assistant tool-call turns in history even when narration is internal', () => { - expect(shouldDropMessageFromHistory({ - role: 'assistant', - content: [ - { type: 'text', text: '生成中,稍等 👨‍🚀' }, - { type: 'tool_use', id: 'tool-image', name: 'image_generate', input: { prompt: 'astronaut' } }, - ], - })).toBe(false); - expect(isInternalMessage({ - role: 'assistant', - content: [ - { type: 'text', text: '生成中,稍等 👨‍🚀' }, - { type: 'tool_use', id: 'tool-image', name: 'image_generate', input: { prompt: 'astronaut' } }, - ], - })).toBe(true); - }); - - it('filters assistant turns that only contain chain-of-thought', () => { - expect(isInternalMessage({ - role: 'assistant', - content: [{ type: 'thinking', thinking: 'The user is asking me to continue with an OpenClaw runtime event.' }], - })).toBe(true); - }); -}); diff --git a/tests/unit/chat-internal-sentinel-display.test.ts b/tests/unit/chat-internal-sentinel-display.test.ts deleted file mode 100644 index ce0aefed..00000000 --- a/tests/unit/chat-internal-sentinel-display.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractText, extractTextSegments, stripInternalSentinelLines } from '@/pages/Chat/message-utils'; - -describe('internal sentinel display cleanup', () => { - it('drops a trailing NO_REPLY appended after a real reply', () => { - const text = [ - '今天杭州闷热潮湿,出门带把伞比较稳妥~', - '', - '如有需要我进一步深挖某条 AI 新闻的详情,可以告诉我!', - '', - 'NO_REPLY', - ].join('\n'); - - expect(extractText({ role: 'assistant', content: text })).toBe( - ['今天杭州闷热潮湿,出门带把伞比较稳妥~', '', '如有需要我进一步深挖某条 AI 新闻的详情,可以告诉我!'].join('\n'), - ); - }); - - it('drops a trailing HEARTBEAT_OK and is case-insensitive', () => { - const text = 'All done.\nheartbeat_ok'; - expect(extractText({ role: 'assistant', content: text })).toBe('All done.'); - }); - - it('still returns empty for a whole-message sentinel', () => { - expect(extractText({ role: 'assistant', content: 'NO_REPLY' })).toBe(''); - }); - - it('does not strip the token when embedded mid-sentence', () => { - const text = 'The constant NO_REPLY signals a skipped turn.'; - expect(extractText({ role: 'assistant', content: text })).toBe(text); - }); - - it('removes sentinel-only segments from extractTextSegments', () => { - const message = { - role: 'assistant', - content: [ - { type: 'text', text: 'Here is the weather summary.' }, - { type: 'text', text: 'NO_REPLY' }, - ], - }; - expect(extractTextSegments(message)).toEqual(['Here is the weather summary.']); - }); - - it('stripInternalSentinelLines leaves clean text untouched', () => { - expect(stripInternalSentinelLines('Hello world')).toBe('Hello world'); - }); -}); diff --git a/tests/unit/chat-leading-orphan-tools.test.tsx b/tests/unit/chat-leading-orphan-tools.test.tsx index 025cc833..435e6677 100644 --- a/tests/unit/chat-leading-orphan-tools.test.tsx +++ b/tests/unit/chat-leading-orphan-tools.test.tsx @@ -44,8 +44,6 @@ const { acpState, agentsState, artifactPanelState, chatState, settingsState } = sessions: [{ key: 'agent:main:main', workspacePath: '/workspace' }], currentSessionKey: 'agent:main:main', currentAgentId: 'main', - loading: false, - refresh: vi.fn(), loadSessions: vi.fn().mockResolvedValue(undefined), selectAcpSession: vi.fn(), acknowledgeAcpSessionCreated: vi.fn(), @@ -84,20 +82,12 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, params?: Record | string) => { if (typeof params === 'string') return params; - if (key === 'executionGraph.collapsedSummary') { - return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim(); - } - if (key === 'executionGraph.agentRun') return 'Main execution'; - if (key === 'executionGraph.title') return 'Execution Graph'; - if (key === 'executionGraph.collapseAction') return 'Collapse'; - if (key === 'executionGraph.thinkingLabel') return 'Thinking'; if (key === 'acp.tool') return 'Tool'; if (key === 'acp.completed') return 'Completed'; if (key === 'acp.toolGroupSummary') return `${String(params?.count ?? '')} tool calls`; if (key === 'acp.expandToolGroup') return 'Expand tool calls'; if (key === 'acp.collapseToolGroup') return 'Collapse tool calls'; if (key === 'welcome.subtitle') return 'What can I do for you?'; - if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key; return key; }, }), @@ -212,6 +202,5 @@ describe('Chat leading ACP tool calls', () => { expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument(); expect(screen.getByText('Continue the task')).toBeInTheDocument(); expect(screen.getByText('Finished.')).toBeInTheDocument(); - expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument(); }); }); diff --git a/tests/unit/chat-load-sessions-startup.test.ts b/tests/unit/chat-load-sessions-startup.test.ts index 5b71d721..435bb919 100644 --- a/tests/unit/chat-load-sessions-startup.test.ts +++ b/tests/unit/chat-load-sessions-startup.test.ts @@ -1,72 +1,97 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const runtimeStatus = { - state: 'running', - port: 18789, - connectedAt: 0, -}; - -const { gatewayRpcMock } = vi.hoisted(() => ({ +const { gatewayRpcMock, sessionDeleteMock, sessionRenameMock, sessionSummariesMock } = vi.hoisted(() => ({ gatewayRpcMock: vi.fn(), + sessionDeleteMock: vi.fn(), + sessionRenameMock: vi.fn(), + sessionSummariesMock: vi.fn(), })); vi.mock('@/stores/gateway', () => ({ useGatewayStore: { - getState: () => ({ - status: runtimeStatus, - rpc: gatewayRpcMock, - }), - }, -})); - -vi.mock('@/stores/agents', () => ({ - useAgentsStore: { - getState: () => ({ agents: [] }), + getState: () => ({ rpc: gatewayRpcMock }), }, })); vi.mock('@/lib/host-api', () => ({ hostApi: { sessions: { - summaries: vi.fn().mockResolvedValue({ summaries: [] }), + summaries: sessionSummariesMock, + delete: sessionDeleteMock, + rename: sessionRenameMock, }, }, - hostApiFetch: vi.fn().mockResolvedValue({ success: true, summaries: [] }), })); -describe('chat store loadSessions startup selection', () => { +type SummaryResponse = { + success: boolean; + summaries: Array<{ + sessionKey: string; + firstUserText: string; + lastTimestamp: number; + workspacePath: string; + }>; +}; + +function deferredSummary(): { + promise: Promise; + resolve: (value: SummaryResponse) => void; +} { + let resolve!: (value: SummaryResponse) => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + return { promise, resolve }; +} + +function deferredCatalog(): { + promise: Promise>; + resolve: (value: Record) => void; +} { + let resolve!: (value: Record) => void; + const promise = new Promise>((resolver) => { + resolve = resolver; + }); + return { promise, resolve }; +} + +describe('chat session catalog startup', () => { beforeEach(() => { vi.resetModules(); gatewayRpcMock.mockReset(); - runtimeStatus.connectedAt = Date.now(); + sessionDeleteMock.mockReset().mockResolvedValue({ success: true }); + sessionRenameMock.mockReset().mockResolvedValue({ success: true }); + sessionSummariesMock.mockReset().mockResolvedValue({ success: true, summaries: [] }); }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('exposes only session catalog and selection state', async () => { + const { useChatStore } = await import('@/stores/chat'); + const state = useChatStore.getState(); + + expect(state).not.toHaveProperty('messages'); + expect(state).not.toHaveProperty('loadHistory'); + expect(state).not.toHaveProperty('sendMessage'); + expect(state).not.toHaveProperty('handleRuntimeEvent'); + expect(state).toMatchObject({ + sessions: expect.any(Array), + currentSessionKey: expect.any(String), + currentAgentId: expect.any(String), + sessionLabels: expect.any(Object), + sessionLastActivity: expect.any(Object), + }); }); it('opens the latest non-cron session instead of a cron heartbeat session', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:cron:heartbeat', - label: 'Main Agent heartbeat', - updatedAt: 9_000, - }, - { - key: 'agent:main:session-a', - displayName: 'PDF summary', - updatedAt: 5_000, - }, - ], - }; - } - if (method === 'chat.history') { - return { messages: [] }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions: [ + { key: 'agent:main:cron:heartbeat', label: 'Main Agent heartbeat', updatedAt: 9_000 }, + { key: 'agent:main:session-a', displayName: 'PDF summary', updatedAt: 5_000 }, + ], }); const { useChatStore } = await import('@/stores/chat'); @@ -74,7 +99,6 @@ describe('chat store loadSessions startup selection', () => { currentSessionKey: 'agent:main:main', currentAgentId: 'main', sessions: [], - messages: [], sessionLabels: {}, sessionLastActivity: {}, }); @@ -82,495 +106,397 @@ describe('chat store loadSessions startup selection', () => { await useChatStore.getState().loadSessions(); expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-a'); + expect(gatewayRpcMock).toHaveBeenCalledTimes(1); + expect(gatewayRpcMock).toHaveBeenCalledWith('sessions.list', { + includeDerivedTitles: true, + includeLastMessage: true, + }); }); - it('hydrates session workspacePath from host session summaries', async () => { - const summariesMock = vi.mocked((await import('@/lib/host-api')).hostApi.sessions.summaries); - summariesMock.mockResolvedValueOnce({ + it('hydrates workspace identity and title activity from session summaries', async () => { + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions: [{ key: 'agent:main:session-a', displayName: 'Chat A', updatedAt: 5_000 }], + }); + sessionSummariesMock.mockResolvedValueOnce({ success: true, summaries: [{ sessionKey: 'agent:main:session-a', - firstUserText: null, - lastTimestamp: null, + firstUserText: 'Summarize this workspace', + lastTimestamp: 1_700_000_000_000, workspacePath: '/Users/alex/workspace/ClawX', }], }); - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { sessions: [{ key: 'agent:main:session-a', displayName: 'Chat A', updatedAt: 5_000 }] }; - } - if (method === 'chat.history') return { messages: [] }; - throw new Error(`Unexpected gateway RPC: ${method}`); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [], + sessionLabels: {}, + sessionLastActivity: {}, }); + await useChatStore.getState().loadSessions(); + await vi.waitFor(() => { + expect(useChatStore.getState().sessionLabels['agent:main:session-a']).toBe('Summarize this workspace'); + }); + + expect(useChatStore.getState().sessions[0]?.workspacePath).toBe('/Users/alex/workspace/ClawX'); + expect(useChatStore.getState().sessionLastActivity['agent:main:session-a']).toBe(1_700_000_000_000); + }); + + it('does not publish a persisted fallback before its workspace summary resolves', async () => { + const summary = deferredSummary(); + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions: [{ key: 'agent:main:session-a', displayName: 'Chat A', updatedAt: 5_000 }], + }); + sessionSummariesMock.mockReturnValueOnce(summary.promise); + const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({ currentSessionKey: 'agent:main:main', currentAgentId: 'main', sessions: [], - messages: [], sessionLabels: {}, sessionLastActivity: {}, }); - await useChatStore.getState().loadSessions(); - await Promise.resolve(); + const loading = useChatStore.getState().loadSessions(); + await vi.waitFor(() => expect(sessionSummariesMock).toHaveBeenCalledWith({ + sessionKeys: ['agent:main:session-a'], + })); - expect(useChatStore.getState().sessions.find((session) => session.key === 'agent:main:session-a')?.workspacePath) - .toBe('/Users/alex/workspace/ClawX'); - }); - - it('preserves a locally-created current session across session refreshes before first send', async () => { - const pendingKey = 'agent:main:session-1711111111111'; - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { key: pendingKey, displayName: 'ACP', updatedAt: 6_000 }, - { key: 'agent:main:session-a', displayName: 'Chat A', updatedAt: 5_000 }, - ], - }; - } - if (method === 'chat.history') return { messages: [] }; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: pendingKey, - currentAgentId: 'main', - sessions: [{ key: pendingKey, displayName: pendingKey, createdLocally: true }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - }); - - await useChatStore.getState().loadSessions(); - - const pendingSession = useChatStore.getState().sessions.find((session) => session.key === pendingKey); - expect(useChatStore.getState().currentSessionKey).toBe(pendingKey); - expect(pendingSession?.createdLocally).toBe(true); - }); - - it('preserves mirrored workspacePath when backend returns the same session without cwd', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { sessions: [{ key: 'agent:main:session-a', displayName: 'Chat A', updatedAt: 5_000 }] }; - } - if (method === 'chat.history') return { messages: [] }; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-a', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-a', workspacePath: '/Users/alex/workspace/ClawX' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - }); - - await useChatStore.getState().loadSessions(); - - expect(useChatStore.getState().sessions.find((session) => session.key === 'agent:main:session-a')?.workspacePath) - .toBe('/Users/alex/workspace/ClawX'); - }); - - it('clears the prior conversation when loadSessions retargets to another session', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:cron:heartbeat', - label: 'Main Agent heartbeat', - updatedAt: 9_000, - }, - { - key: 'agent:main:session-b', - displayName: 'Other chat', - updatedAt: 5_000, - }, - ], - }; - } - if (method === 'chat.history') { - return { messages: [] }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ + expect(useChatStore.getState()).toMatchObject({ currentSessionKey: 'agent:main:main', - currentAgentId: 'main', sessions: [], - messages: [{ role: 'user', content: 'question from another chat' }], - sessionLabels: {}, - sessionLastActivity: {}, }); - await useChatStore.getState().loadSessions(); - - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-b'); - expect(useChatStore.getState().messages).toEqual([]); - }); - - it('hides placeholder feishu sessions but keeps real desktop history', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:feishu:ou_69c24802fa248625f7965a', - updatedAt: 9_000, - }, - { - key: 'agent:main:session-a', - displayName: 'Desktop chat', - updatedAt: 5_000, - }, - ], - }; - } - if (method === 'chat.history') { - return { messages: [] }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - }); - - await useChatStore.getState().loadSessions(); - - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual(['agent:main:session-a']); - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-a'); - }); - - it('starts a fresh session when the current default is a hidden heartbeat even if visible history exists', async () => { - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111); - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:main', - displayName: 'ClawX', - lastMessagePreview: '[OpenClaw heartbeat poll]', - updatedAt: 9_000, - }, - { - key: 'agent:main:session-a', - displayName: 'Visible desktop chat', - lastMessagePreview: 'Summarize the repository structure', - updatedAt: 5_000, - }, - ], - }; - } - if (method === 'chat.history') { - return { messages: [] }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [], - messages: [{ role: 'assistant', content: 'stale visible content' }], - sessionLabels: {}, - sessionLastActivity: {}, - }); - - await useChatStore.getState().loadSessions(); - - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-1711111111111'); - expect(useChatStore.getState().currentSessionKey).not.toBe('agent:main:session-a'); - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([ - 'agent:main:session-a', - 'agent:main:session-1711111111111', - ]); - expect( - useChatStore.getState().sessions.find( - (session) => session.key === 'agent:main:session-1711111111111', - )?.createdLocally, - ).toBe(true); - nowSpy.mockRestore(); - }); - - it('starts a fresh session when the default main session is a heartbeat poll acknowledgement', async () => { - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111); - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:main', - label: '[OpenClaw heartbeat poll]', - displayName: '[OpenClaw heartbeat poll]', - derivedTitle: '[OpenClaw heartbeat poll]', - lastMessagePreview: 'HEARTBEAT_OK', - updatedAt: 9_000, - }, - { - key: 'agent:main:session-a', - displayName: 'Visible desktop chat', - lastMessagePreview: 'Summarize the repository structure', - updatedAt: 5_000, - }, - ], - }; - } - if (method === 'chat.history') return { messages: [] }; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [], - messages: [{ role: 'assistant', content: 'stale visible content' }], - sessionLabels: {}, - sessionLastActivity: {}, - }); - - await useChatStore.getState().loadSessions(); - - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-1711111111111'); - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([ - 'agent:main:session-a', - 'agent:main:session-1711111111111', - ]); - expect( - useChatStore.getState().sessions.find( - (session) => session.key === 'agent:main:session-1711111111111', - )?.createdLocally, - ).toBe(true); - nowSpy.mockRestore(); - }); - - it('removes sessions revealed as heartbeat-only by transcript summaries', async () => { - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111); - const summariesMock = vi.mocked((await import('@/lib/host-api')).hostApi.sessions.summaries); - summariesMock.mockResolvedValueOnce({ + summary.resolve({ success: true, - summaries: [ + summaries: [{ + sessionKey: 'agent:main:session-a', + firstUserText: 'Hydrated title', + lastTimestamp: 1_700_000_000_000, + workspacePath: '/workspace/hydrated', + }], + }); + await loading; + + expect(useChatStore.getState()).toMatchObject({ + currentSessionKey: 'agent:main:session-a', + sessions: [expect.objectContaining({ + key: 'agent:main:session-a', + workspacePath: '/workspace/hydrated', + })], + sessionLabels: { 'agent:main:session-a': 'Hydrated title' }, + sessionLastActivity: { 'agent:main:session-a': 1_700_000_000_000 }, + }); + }); + + it('does not undo a session switch while selected workspace hydration is pending', async () => { + const selectedKey = 'agent:main:session-a'; + const switchedKey = 'agent:main:session-b'; + const summary = deferredSummary(); + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions: [ + { key: selectedKey, displayName: 'Chat A', updatedAt: 5_000 }, + { key: switchedKey, label: 'Chat B', workspacePath: '/workspace/b', updatedAt: 4_000 }, + ], + }); + sessionSummariesMock.mockReturnValueOnce(summary.promise); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: selectedKey, + currentAgentId: 'main', + sessions: [ + { key: selectedKey, displayName: 'Chat A', updatedAt: 5_000 }, + { key: switchedKey, label: 'Chat B', workspacePath: '/workspace/b', updatedAt: 4_000 }, + ], + sessionLabels: { [switchedKey]: 'Chat B' }, + sessionLastActivity: {}, + }); + + const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + await vi.waitFor(() => expect(sessionSummariesMock).toHaveBeenCalledWith({ + sessionKeys: [selectedKey], + })); + useChatStore.getState().switchSession(switchedKey); + + summary.resolve({ + success: true, + summaries: [{ + sessionKey: selectedKey, + firstUserText: 'Stale A', + lastTimestamp: 1_700_000_000_000, + workspacePath: '/workspace/a', + }], + }); + await loading; + + expect(gatewayRpcMock).toHaveBeenCalledTimes(2); + expect(useChatStore.getState().currentSessionKey).toBe(switchedKey); + expect(useChatStore.getState().sessions.find((session) => session.key === switchedKey)).toMatchObject({ + workspacePath: '/workspace/b', + }); + }); + + it('does not undo a locally created and selected session while workspace hydration is pending', async () => { + const selectedKey = 'agent:main:session-a'; + const localKey = 'agent:main:session-1711111111111'; + const summary = deferredSummary(); + vi.spyOn(Date, 'now').mockReturnValue(1_711_111_111_111); + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions: [{ key: selectedKey, displayName: 'Chat A', updatedAt: 5_000 }], + }); + sessionSummariesMock.mockReturnValueOnce(summary.promise); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: selectedKey, + currentAgentId: 'main', + sessions: [{ key: selectedKey, displayName: 'Chat A', updatedAt: 5_000 }], + sessionLabels: {}, + sessionLastActivity: {}, + }); + + const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + await vi.waitFor(() => expect(sessionSummariesMock).toHaveBeenCalledTimes(1)); + useChatStore.getState().newSession(); + useChatStore.getState().selectAcpSession(localKey, '/workspace/local'); + + summary.resolve({ + success: true, + summaries: [{ + sessionKey: selectedKey, + firstUserText: 'Stale A', + lastTimestamp: 1_700_000_000_000, + workspacePath: '/workspace/a', + }], + }); + await loading; + + expect(gatewayRpcMock).toHaveBeenCalledTimes(2); + expect(useChatStore.getState().currentSessionKey).toBe(localKey); + expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({ + key: localKey, + createdLocally: true, + workspacePath: '/workspace/local', + })); + }); + + it('does not resurrect a successfully deleted session after delayed workspace hydration', async () => { + const deletedKey = 'agent:main:session-a'; + const survivorKey = 'agent:main:session-b'; + const summary = deferredSummary(); + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions: [ + { key: deletedKey, displayName: 'Chat A', updatedAt: 5_000 }, + { key: survivorKey, label: 'Chat B', workspacePath: '/workspace/b', updatedAt: 4_000 }, + ], + }); + sessionSummariesMock.mockReturnValueOnce(summary.promise); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: deletedKey, + currentAgentId: 'main', + sessions: [ + { key: deletedKey, displayName: 'Chat A', updatedAt: 5_000 }, + { key: survivorKey, label: 'Chat B', workspacePath: '/workspace/b', updatedAt: 4_000 }, + ], + sessionLabels: { [deletedKey]: 'Chat A', [survivorKey]: 'Chat B' }, + sessionLastActivity: {}, + }); + + const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + await vi.waitFor(() => expect(sessionSummariesMock).toHaveBeenCalledTimes(1)); + await expect(useChatStore.getState().deleteSession(deletedKey)).resolves.toEqual({ success: true }); + + summary.resolve({ + success: true, + summaries: [{ + sessionKey: deletedKey, + firstUserText: 'Stale A', + lastTimestamp: 1_700_000_000_000, + workspacePath: '/workspace/a', + }], + }); + await loading; + + expect(gatewayRpcMock).toHaveBeenCalledTimes(2); + expect(useChatStore.getState().currentSessionKey).toBe(survivorKey); + expect(useChatStore.getState().sessions.some((session) => session.key === deletedKey)).toBe(false); + expect(useChatStore.getState().sessionLabels[deletedKey]).toBeUndefined(); + }); + + it('does not publish a stale sessions.list response after a local delete', async () => { + const deletedKey = 'agent:main:session-a'; + const survivorKey = 'agent:main:session-b'; + const staleCatalog = deferredCatalog(); + const refreshedCatalog = deferredCatalog(); + gatewayRpcMock + .mockReturnValueOnce(staleCatalog.promise) + .mockReturnValueOnce(refreshedCatalog.promise); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: deletedKey, + currentAgentId: 'main', + sessions: [ + { key: deletedKey, label: 'Chat A', workspacePath: '/workspace/a', updatedAt: 5_000 }, + { key: survivorKey, label: 'Chat B', workspacePath: '/workspace/b', updatedAt: 4_000 }, + ], + sessionLabels: { [deletedKey]: 'Chat A', [survivorKey]: 'Chat B' }, + sessionLastActivity: {}, + }); + + const loading = useChatStore.getState().loadSessions({ force: true }); + await vi.waitFor(() => expect(gatewayRpcMock).toHaveBeenCalledTimes(1)); + await expect(useChatStore.getState().deleteSession(deletedKey)).resolves.toEqual({ success: true }); + + staleCatalog.resolve({ + ts: 1, + sessions: [ + { key: deletedKey, label: 'Chat A', workspacePath: '/workspace/a', updatedAt: 5_000 }, + { key: survivorKey, label: 'Chat B', workspacePath: '/workspace/b', updatedAt: 4_000 }, + ], + }); + await vi.waitFor(() => expect(gatewayRpcMock).toHaveBeenCalledTimes(2)); + expect(useChatStore.getState().sessions.some((session) => session.key === deletedKey)).toBe(false); + expect(useChatStore.getState().currentSessionKey).toBe(survivorKey); + + refreshedCatalog.resolve({ + ts: 2, + sessions: [{ key: survivorKey, label: 'Chat B', workspacePath: '/workspace/b', updatedAt: 4_000 }], + }); + await loading; + + expect(useChatStore.getState().sessions.some((session) => session.key === deletedKey)).toBe(false); + expect(useChatStore.getState().sessionLabels[deletedKey]).toBeUndefined(); + }); + + it('does not publish a stale sessions.list label after a local rename', async () => { + const sessionKey = 'agent:main:session-a'; + const staleCatalog = deferredCatalog(); + const refreshedCatalog = deferredCatalog(); + gatewayRpcMock + .mockReturnValueOnce(staleCatalog.promise) + .mockReturnValueOnce(refreshedCatalog.promise); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: sessionKey, + currentAgentId: 'main', + sessions: [{ key: sessionKey, label: 'Old title', workspacePath: '/workspace/a', updatedAt: 5_000 }], + sessionLabels: { [sessionKey]: 'Old title' }, + sessionLastActivity: {}, + }); + + const loading = useChatStore.getState().loadSessions({ force: true }); + await vi.waitFor(() => expect(gatewayRpcMock).toHaveBeenCalledTimes(1)); + await useChatStore.getState().renameSession(sessionKey, 'New title'); + + staleCatalog.resolve({ + ts: 1, + sessions: [{ key: sessionKey, label: 'Old title', workspacePath: '/workspace/a', updatedAt: 5_000 }], + }); + await vi.waitFor(() => expect(gatewayRpcMock).toHaveBeenCalledTimes(2)); + expect(useChatStore.getState().sessionLabels[sessionKey]).toBe('New title'); + expect(useChatStore.getState().sessions[0]?.label).toBe('New title'); + + refreshedCatalog.resolve({ + ts: 2, + sessions: [{ key: sessionKey, label: 'New title', workspacePath: '/workspace/a', updatedAt: 5_000 }], + }); + await loading; + + expect(useChatStore.getState().sessionLabels[sessionKey]).toBe('New title'); + expect(useChatStore.getState().sessions[0]?.label).toBe('New title'); + }); + + it('preserves a local ACP placeholder and mirrored workspace across catalog refreshes', async () => { + const pendingKey = 'agent:main:session-1711111111111'; + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions: [ + { key: pendingKey, displayName: 'ACP', updatedAt: 6_000 }, + { key: 'agent:main:session-a', displayName: 'Chat A', updatedAt: 5_000 }, + ], + }); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [], + sessionLabels: {}, + sessionLastActivity: {}, + }); + useChatStore.getState().selectAcpSession(pendingKey, '/Users/alex/workspace/ClawX'); + + await useChatStore.getState().loadSessions(); + + expect(useChatStore.getState().currentSessionKey).toBe(pendingKey); + expect(useChatStore.getState().sessions.find((session) => session.key === pendingKey)).toMatchObject({ + createdLocally: true, + workspacePath: '/Users/alex/workspace/ClawX', + }); + }); + + it('starts a fresh local session when the selected session is heartbeat-only', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1711111111111); + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions: [ { - sessionKey: 'agent:main:session-heartbeat', - firstUserText: null, - lastTimestamp: 9_000, - workspacePath: null, - heartbeatOnly: true, + key: 'agent:main:main', + displayName: 'ClawX', + lastMessagePreview: '[OpenClaw heartbeat poll]', + updatedAt: 9_000, + }, + { + key: 'agent:main:session-a', + displayName: 'Visible desktop chat', + lastMessagePreview: 'Summarize the repository structure', + updatedAt: 5_000, }, ], }); - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:session-heartbeat', - displayName: 'ClawX', - updatedAt: 9_000, - }, - { - key: 'agent:main:session-a', - displayName: 'Visible desktop chat', - lastMessagePreview: 'Summarize the repository structure', - updatedAt: 5_000, - }, - ], - }; - } - if (method === 'chat.history') { - return { messages: [] }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); - }); const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({ currentSessionKey: 'agent:main:main', - currentAgentId: 'main', sessions: [], - messages: [{ role: 'assistant', content: 'stale visible content' }], - sessionLabels: {}, - sessionLastActivity: {}, - }); - - await useChatStore.getState().loadSessions(); - await Promise.resolve(); - await Promise.resolve(); - - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-1711111111111'); - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([ - 'agent:main:session-a', - 'agent:main:session-1711111111111', - ]); - expect(useChatStore.getState().sessionLabels['agent:main:session-heartbeat']).toBeUndefined(); - nowSpy.mockRestore(); - }); - - it('removes sessions revealed as heartbeat-only by a cached sidebar label', async () => { - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111); - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:session-heartbeat', - displayName: 'ClawX', - updatedAt: 9_000, - }, - { - key: 'agent:main:session-a', - displayName: 'Visible desktop chat', - lastMessagePreview: 'Summarize the repository structure', - updatedAt: 5_000, - }, - ], - }; - } - if (method === 'chat.history') return { messages: [] }; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-heartbeat', - currentAgentId: 'main', - sessions: [], - messages: [{ role: 'assistant', content: 'stale visible content' }], - sessionLabels: { 'agent:main:session-heartbeat': '[OpenClaw heartbeat poll]' }, - sessionLastActivity: { 'agent:main:session-heartbeat': 9_000 }, - }); - - await useChatStore.getState().loadSessions(); - - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-1711111111111'); - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([ - 'agent:main:session-a', - 'agent:main:session-1711111111111', - ]); - expect(useChatStore.getState().sessionLabels['agent:main:session-heartbeat']).toBeUndefined(); - expect(useChatStore.getState().sessionLastActivity['agent:main:session-heartbeat']).toBeUndefined(); - nowSpy.mockRestore(); - }); - - it('clears stale heartbeat sidebar labels without hiding sessions that have real display names', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:session-real', - displayName: 'Project kickoff notes', - updatedAt: 8_000, - }, - ], - }; - } - if (method === 'chat.history') return { messages: [] }; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [], - messages: [], - sessionLabels: { 'agent:main:session-real': '[OpenClaw heartbeat poll]' }, - sessionLastActivity: {}, - }); - - await useChatStore.getState().loadSessions(); - - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual(['agent:main:session-real']); - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-real'); - expect(useChatStore.getState().sessionLabels['agent:main:session-real']).toBeUndefined(); - }); - - it('shows feishu sessions when they contain real channel messages', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:feishu:ou_69c24802fa248625f7965a', - lastMessagePreview: '你好,来自飞书', - updatedAt: 9_000, - }, - { - key: 'agent:main:session-a', - displayName: 'Desktop chat', - updatedAt: 5_000, - }, - ], - }; - } - if (method === 'chat.history') { - return { messages: [] }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [], - messages: [], sessionLabels: {}, sessionLastActivity: {}, }); await useChatStore.getState().loadSessions(); - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([ - 'agent:main:feishu:ou_69c24802fa248625f7965a', - 'agent:main:session-a', - ]); - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-a'); + expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-1711111111111'); + expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({ + key: 'agent:main:session-1711111111111', + createdLocally: true, + })); }); - it('keeps the default main ghost session when only cron sessions exist', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { - key: 'agent:main:cron:heartbeat', - label: 'Main Agent heartbeat', - updatedAt: 9_000, - }, - ], - }; - } - if (method === 'chat.history') { - return { messages: [] }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); + it.each([ + ['empty', []], + ['cron-only', [{ key: 'agent:main:cron:heartbeat', label: 'heartbeat', updatedAt: 9_000 }]], + ])('creates a local default placeholder for an %s catalog', async (_catalogType, sessions) => { + gatewayRpcMock.mockResolvedValue({ + ts: 1, + sessions, }); const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({ currentSessionKey: 'agent:main:main', - currentAgentId: 'main', sessions: [], - messages: [], sessionLabels: {}, sessionLastActivity: {}, }); @@ -578,6 +504,103 @@ describe('chat store loadSessions startup selection', () => { await useChatStore.getState().loadSessions(); expect(useChatStore.getState().currentSessionKey).toBe('agent:main:main'); - expect(useChatStore.getState().sessions.some((session) => session.key === 'agent:main:main')).toBe(true); + expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({ + key: 'agent:main:main', + createdLocally: true, + })); + }); + + it('preserves an acknowledged ACP session across stale catalogs until canonical visibility', async () => { + const pendingKey = 'agent:main:session-created'; + const survivorKey = 'agent:main:session-existing'; + gatewayRpcMock + .mockResolvedValueOnce({ + ts: 1, + sessions: [{ key: survivorKey, displayName: 'Existing', workspacePath: '/workspace/existing' }], + }) + .mockResolvedValueOnce({ + ts: 2, + sessions: [{ key: survivorKey, displayName: 'Existing', workspacePath: '/workspace/existing' }], + }) + .mockResolvedValueOnce({ + ts: 3, + sessions: [ + { key: pendingKey, displayName: 'Created' }, + { key: survivorKey, displayName: 'Existing', workspacePath: '/workspace/existing' }, + ], + }) + .mockResolvedValueOnce({ + ts: 4, + sessions: [{ key: survivorKey, displayName: 'Existing', workspacePath: '/workspace/existing' }], + }); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: pendingKey, + currentAgentId: 'main', + sessions: [{ + key: pendingKey, + displayName: pendingKey, + createdLocally: true, + workspacePath: '/workspace/created', + }], + sessionLabels: {}, + sessionLastActivity: {}, + }); + useChatStore.getState().acknowledgeAcpSessionCreated(pendingKey, '/workspace/created'); + + for (let staleSnapshot = 0; staleSnapshot < 2; staleSnapshot += 1) { + await useChatStore.getState().loadSessions({ force: true }); + expect(useChatStore.getState().currentSessionKey).toBe(pendingKey); + expect(useChatStore.getState().sessions.find((session) => session.key === pendingKey)).toMatchObject({ + key: pendingKey, + workspacePath: '/workspace/created', + }); + expect(useChatStore.getState().sessions.find((session) => session.key === pendingKey)).not.toHaveProperty( + 'createdLocally', + true, + ); + } + + await useChatStore.getState().loadSessions({ force: true }); + expect(useChatStore.getState().currentSessionKey).toBe(pendingKey); + expect(useChatStore.getState().sessions.find((session) => session.key === pendingKey)).toMatchObject({ + key: pendingKey, + displayName: 'Created', + workspacePath: '/workspace/created', + }); + + await useChatStore.getState().loadSessions({ force: true }); + expect(useChatStore.getState().currentSessionKey).toBe(survivorKey); + expect(useChatStore.getState().sessions.some((session) => session.key === pendingKey)).toBe(false); + }); + + it('clears acknowledged-session protection when a canonical event observes the key', async () => { + const pendingKey = 'agent:main:session-event-created'; + const survivorKey = 'agent:main:session-existing'; + gatewayRpcMock.mockResolvedValue({ + ts: 2, + sessions: [{ key: survivorKey, displayName: 'Existing', workspacePath: '/workspace/existing' }], + }); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: pendingKey, + currentAgentId: 'main', + sessions: [{ key: pendingKey, createdLocally: true, workspacePath: '/workspace/created' }], + sessionLabels: {}, + sessionLastActivity: {}, + }); + useChatStore.getState().acknowledgeAcpSessionCreated(pendingKey, '/workspace/created'); + useChatStore.getState().handleSessionsChanged({ + ts: 1, + sessionKey: pendingKey, + session: { key: pendingKey, displayName: 'Created by event' }, + }); + + await useChatStore.getState().loadSessions({ force: true }); + + expect(useChatStore.getState().currentSessionKey).toBe(survivorKey); + expect(useChatStore.getState().sessions.some((session) => session.key === pendingKey)).toBe(false); }); }); diff --git a/tests/unit/chat-message.test.tsx b/tests/unit/chat-message.test.tsx deleted file mode 100644 index 7d3409b0..00000000 --- a/tests/unit/chat-message.test.tsx +++ /dev/null @@ -1,515 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { ChatMessage } from '@/pages/Chat/ChatMessage'; -import type { RawMessage } from '@/stores/chat'; - -vi.mock('@/lib/file-preview-client', () => ({ - readBinaryFile: vi.fn(), - statFile: vi.fn(async (path: string) => { - if (path.includes('missing') || path.includes('不存在')) { - return { ok: false, error: 'notFound' }; - } - const isFile = /\.[A-Za-z0-9]+$/.test(path); - return { - ok: true, - isFile, - isDir: !isFile, - size: isFile ? 1024 : 0, - }; - }), -})); - -describe('ChatMessage attachment dedupe', () => { - it('keeps attachment-only assistant replies visible even when process attachments are suppressed', () => { - const message: RawMessage = { - role: 'assistant', - content: [], - _attachedFiles: [ - { - fileName: 'artifact.png', - mimeType: 'image/png', - fileSize: 0, - preview: '/tmp/artifact.png', - filePath: '/tmp/artifact.png', - source: 'tool-result', - }, - ], - }; - - render( - , - ); - - expect(screen.getByAltText('artifact.png')).toBeInTheDocument(); - }); - - it('keeps image artifacts visible alongside reply text when process attachments are suppressed', () => { - // Regression for media outgoing being silently dropped when the agent - // accompanies a `MEDIA:/path.png` artifact with any narration text: - // process-attachment filtering used to require PDF/XLSX/dir/skill but - // had no carve-out for images, so the file card never rendered. - const message: RawMessage = { - role: 'assistant', - content: 'Screenshot taken, sending it to you as an attachment.', - _attachedFiles: [ - { - fileName: 'desktop_screenshot.png', - mimeType: 'image/png', - fileSize: 1234, - preview: 'data:image/png;base64,abc', - filePath: '/tmp/desktop_screenshot.png', - source: 'tool-result', - }, - ], - }; - - render( - , - ); - - expect(screen.getByAltText('desktop_screenshot.png')).toBeInTheDocument(); - }); - - it('shows an explicit loading state for image artifacts before preview hydration finishes', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Image generated.', - _attachedFiles: [ - { - fileName: 'generated.png', - mimeType: 'image/png', - fileSize: 0, - preview: null, - gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full', - source: 'gateway-media', - }, - ], - }; - - render(); - - expect(screen.getByTestId('image-preview-loading')).toBeInTheDocument(); - expect(screen.queryByTestId('image-preview-unavailable')).not.toBeInTheDocument(); - }); - - it('shows an unavailable state after image preview hydration gives up', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Image generated.', - _attachedFiles: [ - { - fileName: 'generated.png', - mimeType: 'image/png', - fileSize: 0, - preview: null, - previewStatus: 'unavailable', - gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full', - source: 'gateway-media', - }, - ], - }; - - render(); - - expect(screen.getByTestId('image-preview-unavailable')).toBeInTheDocument(); - expect(screen.queryByTestId('image-preview-loading')).not.toBeInTheDocument(); - }); - - it('keeps message-ref image artifacts visible alongside reply text when process attachments are suppressed', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Compressed, sending it to you:', - _attachedFiles: [ - { - fileName: 'desktop_screenshot.jpg', - mimeType: 'image/jpeg', - fileSize: 837_000, - preview: 'data:image/jpeg;base64,xyz', - filePath: '/tmp/desktop_screenshot.jpg', - source: 'message-ref', - }, - ], - }; - - render( - , - ); - - expect(screen.getByAltText('desktop_screenshot.jpg')).toBeInTheDocument(); - }); - - it('keeps html artifacts visible when process attachments are suppressed', async () => { - const message: RawMessage = { - role: 'assistant', - content: '已生成 /workspace/demo.html', - }; - - render(); - - expect(await screen.findByText('demo.html')).toBeInTheDocument(); - }); - - it('hides generic tool-result markdown attachments when process attachments are suppressed', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Coder has finished the analysis, here are the conclusions.', - _attachedFiles: [ - { - fileName: 'CHECKLIST.md', - mimeType: 'text/markdown', - fileSize: 433, - preview: null, - filePath: '/Users/bytedance/.openclaw/workspace/CHECKLIST.md', - source: 'tool-result', - }, - ], - }; - - render(); - - expect(screen.getByText('Coder has finished the analysis, here are the conclusions.')).toBeInTheDocument(); - expect(screen.queryByText('CHECKLIST.md')).not.toBeInTheDocument(); - }); - - it('keeps attached SKILL.md visible when process attachments are suppressed', () => { - const message: RawMessage = { - role: 'assistant', - content: '这是文件。', - _attachedFiles: [ - { - fileName: 'SKILL.md', - mimeType: 'text/markdown', - fileSize: 128, - preview: null, - filePath: '/workspace/skills/open-xueqiu/SKILL.md', - source: 'tool-result', - }, - ], - }; - - render(); - - expect(screen.getByText('SKILL.md')).toBeInTheDocument(); - }); - - it('keeps pdf and spreadsheet artifacts visible when process attachments are suppressed', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Here are the generated files.', - _attachedFiles: [ - { - fileName: 'sales.xlsx', - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - fileSize: 1024, - preview: null, - filePath: '/tmp/sales.xlsx', - source: 'message-ref', - }, - { - fileName: 'report.pdf', - mimeType: 'application/pdf', - fileSize: 2048, - preview: null, - filePath: '/tmp/report.pdf', - source: 'tool-result', - }, - ], - }; - - render( - , - ); - - expect(screen.getByText('sales.xlsx')).toBeInTheDocument(); - expect(screen.getByText('report.pdf')).toBeInTheDocument(); - }); - - it('derives preview cards from assistant text paths when attachments are missing', async () => { - const message: RawMessage = { - role: 'assistant', - content: '已生成测试 PDF 文件: 测试PDF文件.pdf 位置: `/Users/zhonghaolu/.openclaw/workspace/测试PDF文件.pdf`', - }; - - render(); - - expect(await screen.findByText('测试PDF文件.pdf')).toBeInTheDocument(); - }); - - it('derives skill directory cards from assistant text paths', async () => { - const message: RawMessage = { - role: 'assistant', - content: '名称: open-eastmoney\n位置: ~/.openclaw/skills/open-eastmoney\n校验结果:通过', - }; - - render(); - - expect(await screen.findByText('open-eastmoney')).toBeInTheDocument(); - expect(screen.getByText('文件夹')).toBeInTheDocument(); - }); - - it('keeps unicode Windows skill directory paths as cards', async () => { - const message: RawMessage = { - role: 'assistant', - content: String.raw`位置: C:\Users\张三\.openclaw\skills\打开东方财富`, - }; - - render(); - - expect(await screen.findByText('打开东方财富')).toBeInTheDocument(); - expect(screen.getByText('文件夹')).toBeInTheDocument(); - }); - - it('shows SKILL.md as a previewable file card instead of a folder', async () => { - const onOpenFile = vi.fn(); - const message: RawMessage = { - role: 'assistant', - content: '位置: ~/.openclaw/skills/open-baidu\nMarkdown 文件: ~/.openclaw/skills/open-baidu/SKILL.md', - }; - - render(); - - expect(await screen.findByText('open-baidu')).toBeInTheDocument(); - expect(await screen.findByText('SKILL.md')).toBeInTheDocument(); - expect(screen.getAllByText('文件夹')).toHaveLength(1); - - fireEvent.click(screen.getByText('SKILL.md')); - expect(onOpenFile).toHaveBeenCalledWith(expect.objectContaining({ - fileName: 'SKILL.md', - filePath: '~/.openclaw/skills/open-baidu/SKILL.md', - mimeType: 'text/markdown', - })); - }); - - it('does not show cards for hallucinated missing paths', async () => { - const message: RawMessage = { - role: 'assistant', - content: '不存在的文件: ~/.openclaw/skills/missing-skill/SKILL.md', - }; - - render(); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(screen.queryByText('SKILL.md')).not.toBeInTheDocument(); - }); - - it('continues hiding non-preview process attachments when process attachments are suppressed', () => { - const message: RawMessage = { - role: 'assistant', - content: 'I also used a temporary file.', - _attachedFiles: [ - { - fileName: 'debug.log', - mimeType: 'text/plain', - fileSize: 1024, - preview: null, - filePath: '/tmp/debug.log', - source: 'message-ref', - }, - ], - }; - - render( - , - ); - - expect(screen.queryByText('debug.log')).not.toBeInTheDocument(); - }); -}); - -describe('ChatMessage LaTeX rendering', () => { - it('renders inline `$...$` math with KaTeX', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Mass-energy equivalence: $E=mc^2$ is famous.', - }; - const { container } = render(); - expect(container.querySelector('.katex')).not.toBeNull(); - }); - - it('renders display `$$...$$` math as a block', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Definite integral:\n\n$$\n\\int_0^1 x\\,dx = \\frac{1}{2}\n$$\n', - }; - const { container } = render(); - expect(container.querySelector('.katex-display')).not.toBeNull(); - }); - - it('renders `\\(...\\)` inline math (OpenAI-style escaping)', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Quadratic formula: \\(x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}\\).', - }; - const { container } = render(); - expect(container.querySelector('.katex')).not.toBeNull(); - expect(container.querySelector('.katex-display')).toBeNull(); - }); - - it('renders `\\[...\\]` block math (OpenAI-style escaping)', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Sum formula:\n\n\\[\\sum_{i=1}^n i = \\frac{n(n+1)}{2}\\]', - }; - const { container } = render(); - expect(container.querySelector('.katex-display')).not.toBeNull(); - }); - - it('does not rewrite `\\(` inside code fences', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Code sample:\n\n```\nprintf("\\(hello\\)")\n```\n', - }; - const { container } = render(); - expect(container.textContent).toContain('\\(hello\\)'); - expect(container.querySelector('.katex')).toBeNull(); - }); -}); - -describe('ChatMessage word wrapping', () => { - // Regression for #931: word-break:break-all on the message bubble wrappers - // forced English words to split mid-character. Long unbreakable tokens - // (URLs, identifiers) still wrap via overflow-wrap:break-word; inline - // and children keep break-all because those carry non-prose - // tokens where mid-char breaks are still desirable. - it('does not apply break-all to the assistant prose wrapper', () => { - const message: RawMessage = { - role: 'assistant', - content: 'The neural network response should wrap at word boundaries.', - }; - const { container } = render(); - const prose = container.querySelector('.prose'); - expect(prose).not.toBeNull(); - expect(prose?.classList.contains('break-all')).toBe(false); - expect(prose?.classList.contains('break-words')).toBe(true); - }); - - it('does not apply break-all to user message text', () => { - const message: RawMessage = { - role: 'user', - content: 'A user-typed sentence that should also wrap by words, not characters.', - }; - const { container } = render(); - const paragraph = container.querySelector('p.whitespace-pre-wrap'); - expect(paragraph).not.toBeNull(); - expect(paragraph?.classList.contains('break-all')).toBe(false); - expect(paragraph?.classList.contains('break-words')).toBe(true); - }); - - it('keeps break-all on inline code so long identifiers can still break mid-token', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Use `someVeryLongIdentifierNameThatShouldStillBreakAnywhere` here.', - }; - const { container } = render(); - const inlineCode = container.querySelector('.prose code'); - expect(inlineCode).not.toBeNull(); - expect(inlineCode?.classList.contains('break-all')).toBe(true); - }); - - // Regression: fenced code blocks used to set only `overflow-x-auto`, which - // hid long log lines / paths behind a horizontal scroll that the chat - // viewport often clipped. Long lines must now wrap inside the bubble. - it('wraps fenced code block contents instead of overflowing horizontally', () => { - const longLine = 'config change requires channel reload (wecom) — deferring until 2 operation(s), 1 reply(ies), 1 embedded run(s) complete'; - const message: RawMessage = { - role: 'assistant', - content: ['Gateway log:', '', '```', longLine, '```'].join('\n'), - }; - const { container } = render(); - const codeBlock = container.querySelector('.prose pre'); - expect(codeBlock).not.toBeNull(); - expect(codeBlock?.classList.contains('whitespace-pre-wrap')).toBe(true); - expect(codeBlock?.classList.contains('break-words')).toBe(true); - }); -}); - -describe('ChatMessage reply styling', () => { - it('renders assistant replies as plain Markdown without a rounded bubble wrapper', () => { - const message: RawMessage = { - role: 'assistant', - content: 'Direct Markdown reply with **bold** text.', - }; - - const { container } = render(); - const prose = container.querySelector('.prose'); - expect(prose).not.toBeNull(); - expect(prose?.classList.contains('rounded-2xl')).toBe(false); - expect(prose?.classList.contains('bg-black/5')).toBe(false); - expect(prose?.classList.contains('dark:bg-white/5')).toBe(false); - expect(prose?.parentElement?.classList.contains('rounded-2xl')).toBe(false); - }); - - it('keeps user messages in the blue rounded bubble', () => { - const message: RawMessage = { - role: 'user', - content: 'Keep the prompt bubble.', - }; - - const { container } = render(); - const bubble = container.querySelector('.rounded-2xl.bg-brand'); - expect(bubble).not.toBeNull(); - expect(bubble).toHaveTextContent('Keep the prompt bubble.'); - }); -}); - -describe('ChatMessage image copy', () => { - beforeEach(() => { - class MockClipboardItem { - constructor(public items: Record) {} - } - Object.assign(globalThis, { ClipboardItem: MockClipboardItem }); - Object.assign(navigator, { - clipboard: { - write: vi.fn(async () => undefined), - writeText: vi.fn(async () => undefined), - }, - }); - }); - - it('copies image bytes instead of the media URL text when an image attachment is present', async () => { - const { readBinaryFile } = await import('@/lib/file-preview-client'); - vi.mocked(readBinaryFile).mockResolvedValueOnce({ - ok: true, - data: Uint8Array.from([137, 80, 78, 71]), - mimeType: 'image/png', - }); - - const message: RawMessage = { - role: 'assistant', - content: 'http://127.0.0.1:18789/api/chat/media/outgoing/agent/main/full', - _attachedFiles: [ - { - fileName: 'cat.png', - mimeType: 'image/png', - fileSize: 1234, - preview: null, - filePath: '/tmp/cat.png', - source: 'tool-result', - }, - ], - }; - - render(); - - fireEvent.click(screen.getByRole('button')); - await vi.waitFor(() => { - expect(navigator.clipboard.write).toHaveBeenCalledTimes(1); - }); - expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/chat-optimistic-match.test.ts b/tests/unit/chat-optimistic-match.test.ts deleted file mode 100644 index aa9b126c..00000000 --- a/tests/unit/chat-optimistic-match.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - dropRedundantOptimisticUserMessages, - hasOptimisticServerEcho, - matchesOptimisticUserMessage, - mergePendingOptimisticUserMessages, - rememberPendingOptimisticUserMessage, -} from '@/stores/chat/helpers'; - -describe('matchesOptimisticUserMessage', () => { - it('matches when text is identical', () => { - const optimistic = { role: 'user', content: 'run github1', timestamp: 1_700_000_000 } as const; - const candidate = { role: 'user', content: 'run github1', timestamp: 1_700_000_000 } as const; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true); - }); - - it('matches when Gateway prefixes a weekday/timestamp prefix on the echoed user message', () => { - const optimistic = { role: 'user', content: 'run github1', timestamp: 1_700_000_000 } as const; - const candidate = { - role: 'user', - content: '[Wed 2026-04-22 10:30 GMT+8] run github1', - timestamp: 1_700_000_000, - } as const; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true); - }); - - it('matches when the server appends [media attached: ...] to the echoed user message', () => { - const optimistic = { - role: 'user', - content: 'Describe this image', - timestamp: 1_700_000_000, - _attachedFiles: [ - { - fileName: 'shot.png', - mimeType: 'image/png', - fileSize: 123, - preview: null, - filePath: '/tmp/shot.png', - }, - ], - } as const; - const candidate = { - role: 'user', - content: 'Describe this image\n\n[media attached: /tmp/shot.png (image/png) | /tmp/shot.png]', - timestamp: 1_700_000_000, - } as const; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true); - }); - - it('matches when the server strips a [message_id: ...] tag from the user message', () => { - const optimistic = { role: 'user', content: 'hello world', timestamp: 1_700_000_000 } as const; - const candidate = { - role: 'user', - content: 'hello world [message_id: 11111111-2222-3333-4444-555555555555]', - timestamp: 1_700_000_000, - } as const; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true); - }); - - it('matches when Gateway echo timestamp skews within the optimistic window', () => { - const optimistic = { role: 'user', content: '你好,你是什么模型', timestamp: 1_700_000_000 } as const; - const candidate = { - role: 'user', - content: '你好,你是什么模型', - timestamp: 1_700_000_030, - } as const; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true); - }); - - it('rejects unrelated user messages when timestamp skew is too large', () => { - const optimistic = { role: 'user', content: '你好,你是什么模型', timestamp: 1_700_000_000 } as const; - const candidate = { - role: 'user', - content: '你好,你是什么模型', - timestamp: 1_700_030_000, - } as const; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(false); - }); - - it('still rejects unrelated user messages', () => { - const optimistic = { role: 'user', content: 'run github1', timestamp: 1_700_000_000 } as const; - const candidate = { - role: 'user', - content: '[Wed 2026-04-22 10:30 GMT+8] completely different text', - timestamp: 1_700_000_000, - } as const; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(false); - }); - - // Regression for the duplicate-bubble bug reproduced in session - // fa07a446-c107-4252-9948-c063357647bc.jsonl: the Gateway echo carries a - // Sender block, one or more `[media attached: ...]` lines BEFORE the - // `[Mon ... GMT+8]` timestamp prefix, the real user text, and a trailing - // `[media attached: ...]` line. Earlier code stripped the timestamp - // regex before the media-attached lines, so the timestamp anchor - // `^\s*\[(?:Mon|...)]` never matched (the leading `[` was - // `[media attached:`). The normalized comparison text kept the - // `[Mon ...]` prefix and never equalled the bare optimistic text, - // causing dedupe to miss and the message to render twice — the second - // bubble showing the leftover `[Mon ...]` prefix. - it('matches the full Gateway echo with Sender block, leading + trailing media lines, and a timestamp prefix', () => { - const optimistic = { - role: 'user', - content: 'Please help me investigate why I am not getting a reply when I send messages on Discord. ClawX is now in connected status.', - timestamp: 1_700_000_000, - _attachedFiles: [ - { - fileName: 'image---9ad2735c.png', - mimeType: 'image/png', - fileSize: 456, - preview: null, - filePath: '/Users/guoyuliang/.openclaw/media/inbound/image---9ad2735c-21ce-443e-af5c-1cd290c1d8d0.png', - }, - ], - } as const; - const candidate = { - role: 'user', - content: [ - 'Sender (untrusted metadata):', - '```json', - '{', - ' "label": "ClawX (gateway-client)",', - ' "id": "gateway-client",', - ' "name": "ClawX",', - ' "username": "ClawX"', - '}', - '```', - '', - '[media attached: /Users/guoyuliang/.openclaw/media/inbound/image---9ad2735c-21ce-443e-af5c-1cd290c1d8d0.png (image/png)]', - '[Mon 2026-05-18 10:39 GMT+8] Please help me investigate why I am not getting a reply when I send messages on Discord. ClawX is now in connected status.', - '[media attached: /Users/guoyuliang/.openclaw/media/outbound/fa3637d9-98b9-4e77-a176-3f66ca763cf4.png (image/png) | /Users/guoyuliang/.openclaw/media/outbound/fa3637d9-98b9-4e77-a176-3f66ca763cf4.png]', - ].join('\n'), - timestamp: 1_700_000_000, - } as const; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true); - }); -}); - -describe('optimistic user message merge', () => { - it('drops redundant optimistic bubbles when history already contains the echoed user turn', () => { - const sessionKey = 'agent:main:main'; - const optimistic = { - id: 'local-optimistic', - role: 'user' as const, - content: '你好,你是什么模型', - timestamp: 1_700_000_000, - }; - rememberPendingOptimisticUserMessage(sessionKey, optimistic, 1_700_000_000_000); - - const merged = mergePendingOptimisticUserMessages(sessionKey, [ - { - id: 'server-echo', - role: 'user', - content: '[Wed 2026-04-22 10:30 GMT+8] 你好,你是什么模型', - timestamp: 1_700_000_015, - }, - ]); - const deduped = dropRedundantOptimisticUserMessages(sessionKey, merged); - - expect(deduped).toHaveLength(1); - expect(deduped[0]?.id).toBe('server-echo'); - expect(hasOptimisticServerEcho(deduped, optimistic, 1_700_000_000_000)).toBe(true); - }); -}); diff --git a/tests/unit/chat-question-directory.test.tsx b/tests/unit/chat-question-directory.test.tsx index b9956cc9..a8c35cb5 100644 --- a/tests/unit/chat-question-directory.test.tsx +++ b/tests/unit/chat-question-directory.test.tsx @@ -51,8 +51,6 @@ const { acpState, chatState, settingsState } = vi.hoisted(() => ({ sessions: [{ key: 'agent:main:main', workspacePath: '/workspace' }], currentSessionKey: 'agent:main:main', currentAgentId: 'main', - loading: false, - refresh: vi.fn(), loadSessions: vi.fn().mockResolvedValue(undefined), selectAcpSession: vi.fn(), acknowledgeAcpSessionCreated: vi.fn(), @@ -70,33 +68,8 @@ vi.mock('@/stores/acp-chat-session', () => ({ useAcpChatSessionStore: (selector: (state: typeof acpState) => unknown) => selector(acpState), })); -const legacyChatFields = { - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessionLabels: {}, - loadingMoreHistory: false, - hasMoreHistory: false, - sending: false, - error: null, - runError: null, - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - activeRunId: null, - sendMessage: vi.fn(), - abortRun: vi.fn(), - clearError: vi.fn(), - loadMoreHistory: vi.fn(), - loadHistory: vi.fn(), - cleanupEmptySession: vi.fn(), - lastUserMessageAt: null, -}; - vi.mock('@/stores/chat', () => ({ - useChatStore: (selector: (state: typeof chatState & typeof legacyChatFields) => unknown) => selector({ - ...legacyChatFields, - ...chatState, - }), + useChatStore: (selector: (state: typeof chatState) => unknown) => selector(chatState), })); vi.mock('@/stores/settings', () => ({ @@ -205,7 +178,6 @@ describe('Chat question directory', () => { chatState.sessions = [{ key: 'agent:main:main', workspacePath: '/workspace' }]; chatState.currentSessionKey = 'agent:main:main'; chatState.currentAgentId = 'main'; - chatState.refresh.mockReset(); settingsState.chatWorkspacePath = '/workspace'; }); diff --git a/tests/unit/chat-runtime-event-handlers.test.ts b/tests/unit/chat-runtime-event-handlers.test.ts deleted file mode 100644 index 9b36c6a9..00000000 --- a/tests/unit/chat-runtime-event-handlers.test.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const clearErrorRecoveryTimer = vi.fn(); -const clearHistoryPoll = vi.fn(); -const collectToolUpdates = vi.fn(() => []); -const extractImagesAsAttachedFiles = vi.fn(() => []); -const extractMediaRefs = vi.fn(() => []); -const extractRawFilePaths = vi.fn(() => []); -const getMessageText = vi.fn(() => ''); -const getMessageErrorMessage = vi.fn((message: { errorMessage?: string; error_message?: string } | undefined) => - message?.errorMessage ?? message?.error_message ?? null); -const getToolCallFilePath = vi.fn(() => undefined); -const hasErrorRecoveryTimer = vi.fn(() => false); -const hasNonToolAssistantContent = vi.fn(() => true); -const hasPendingToolUse = vi.fn(() => false); -const isInternalMessage = vi.fn(() => false); -const isTerminalAssistantErrorMessage = vi.fn((message: { role?: string; stopReason?: string; stop_reason?: string } | undefined) => { - const stopReason = message?.stopReason ?? message?.stop_reason; - return message?.role === 'assistant' && stopReason === 'error'; -}); -const isToolOnlyMessage = vi.fn(() => false); -const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult' || role === 'toolResult' || role === 'tool_result'); -const makeAttachedFile = vi.fn((ref: { filePath: string; mimeType: string }, source?: 'user-upload' | 'tool-result' | 'message-ref') => ({ - fileName: ref.filePath.split('/').pop() || 'file', - mimeType: ref.mimeType, - fileSize: 0, - preview: null, - filePath: ref.filePath, - source, -})); -const normalizeStreamingMessage = vi.fn((message: unknown) => message); -const scheduleRecoverableRuntimeError = vi.fn((commit: () => void) => commit()); -const isRecoverableRuntimeError = vi.fn((message: string) => /\bterminated\b/i.test(message)); -const snapshotStreamingAssistantMessage = vi.fn((currentStream: unknown) => currentStream ? [currentStream] : []); -const upsertToolStatuses = vi.fn((_current, updates) => updates); - -vi.mock('@/stores/chat/helpers', () => ({ - clearErrorRecoveryTimer: (...args: unknown[]) => clearErrorRecoveryTimer(...args), - clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args), - collectToolUpdates: (...args: unknown[]) => collectToolUpdates(...args), - extractImagesAsAttachedFiles: (...args: unknown[]) => extractImagesAsAttachedFiles(...args), - extractMediaRefs: (...args: unknown[]) => extractMediaRefs(...args), - getMessageErrorMessage: (...args: unknown[]) => getMessageErrorMessage(...args), - extractRawFilePaths: (...args: unknown[]) => extractRawFilePaths(...args), - getMessageText: (...args: unknown[]) => getMessageText(...args), - getToolCallFilePath: (...args: unknown[]) => getToolCallFilePath(...args), - hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args), - hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args), - hasPendingToolUse: (...args: unknown[]) => hasPendingToolUse(...args), - isInternalMessage: (...args: unknown[]) => isInternalMessage(...args), - isTerminalAssistantErrorMessage: (...args: unknown[]) => isTerminalAssistantErrorMessage(...args), - isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args), - isToolResultRole: (...args: unknown[]) => isToolResultRole(...args), - makeAttachedFile: (...args: unknown[]) => makeAttachedFile(...args), - isRecoverableRuntimeError: (...args: unknown[]) => isRecoverableRuntimeError(...args), - normalizeStreamingMessage: (...args: unknown[]) => normalizeStreamingMessage(...args), - scheduleRecoverableRuntimeError: (...args: unknown[]) => scheduleRecoverableRuntimeError(...args), - snapshotStreamingAssistantMessage: (...args: unknown[]) => snapshotStreamingAssistantMessage(...args), - upsertToolStatuses: (...args: unknown[]) => upsertToolStatuses(...args), -})); - -type ChatLikeState = { - sending: boolean; - activeRunId: string | null; - error: string | null; - runError: string | null; - streamingMessage: unknown | null; - streamingTools: unknown[]; - messages: Array>; - pendingToolImages: unknown[]; - pendingFinal: boolean; - lastUserMessageAt: number | null; - streamingText: string; - loadHistory: ReturnType; -}; - -function makeHarness(initial?: Partial) { - let state: ChatLikeState = { - sending: false, - activeRunId: null, - error: 'stale error', - runError: null, - streamingMessage: null, - streamingTools: [], - messages: [], - pendingToolImages: [], - pendingFinal: false, - lastUserMessageAt: null, - streamingText: '', - loadHistory: vi.fn(), - ...initial, - }; - - const set = (partial: Partial | ((s: ChatLikeState) => Partial)) => { - const next = typeof partial === 'function' ? partial(state) : partial; - state = { ...state, ...next }; - }; - const get = () => state; - return { set, get, read: () => state }; -} - -describe('chat runtime event handlers', () => { - beforeEach(() => { - vi.resetAllMocks(); - hasErrorRecoveryTimer.mockReturnValue(false); - isRecoverableRuntimeError.mockImplementation((message: string) => /\bterminated\b/i.test(message)); - scheduleRecoverableRuntimeError.mockImplementation((commit: () => void) => commit()); - collectToolUpdates.mockReturnValue([]); - normalizeStreamingMessage.mockImplementation((message: unknown) => message); - snapshotStreamingAssistantMessage.mockImplementation((currentStream: unknown) => currentStream ? [currentStream as Record] : []); - upsertToolStatuses.mockImplementation((_current, updates) => updates); - }); - - it('marks sending on started event', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ sending: false, activeRunId: null, error: 'err' }); - - handleRuntimeEventState(h.set as never, h.get as never, {}, 'started', 'run-1'); - const next = h.read(); - expect(next.sending).toBe(true); - expect(next.activeRunId).toBe('run-1'); - expect(next.error).toBeNull(); - }); - - it('applies delta event and clears stale error when recovery timer exists', async () => { - hasErrorRecoveryTimer.mockReturnValue(true); - collectToolUpdates.mockReturnValue([{ name: 'tool-a', status: 'running', updatedAt: 1 }]); - - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ - error: 'old', - streamingTools: [], - streamingMessage: { role: 'assistant', content: 'old' }, - }); - const event = { message: { role: 'assistant', content: 'delta' } }; - - handleRuntimeEventState(h.set as never, h.get as never, event, 'delta', 'run-2'); - const next = h.read(); - expect(clearErrorRecoveryTimer).toHaveBeenCalledTimes(1); - expect(next.error).toBeNull(); - expect(next.runError).toBeNull(); - expect(next.streamingMessage).toEqual(event.message); - expect(next.streamingTools).toEqual([{ name: 'tool-a', status: 'running', updatedAt: 1 }]); - }); - - it('loads history when final event has no message', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness(); - - handleRuntimeEventState(h.set as never, h.get as never, {}, 'final', 'run-3'); - const next = h.read(); - expect(next.pendingFinal).toBe(true); - expect(next.streamingMessage).toBeNull(); - expect(h.read().loadHistory).toHaveBeenCalledTimes(1); - }); - - it('marks tool-result attachments before appending them to the final assistant reply', async () => { - extractMediaRefs.mockReturnValue([{ filePath: '/tmp/CHECKLIST.md', mimeType: 'text/markdown' }]); - getMessageText.mockReturnValue('[media attached: /tmp/CHECKLIST.md (text/markdown) | /tmp/CHECKLIST.md]'); - hasNonToolAssistantContent.mockReturnValue(true); - - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ - pendingToolImages: [], - streamingMessage: { - role: 'assistant', - id: 'streaming-assistant', - content: [{ type: 'tool_use', id: 'call-1', name: 'read', input: { filePath: '/tmp/CHECKLIST.md' } }], - }, - }); - - handleRuntimeEventState(h.set as never, h.get as never, { - message: { - role: 'toolResult', - toolCallId: 'call-1', - toolName: 'read', - content: [{ type: 'text', text: '[media attached: /tmp/CHECKLIST.md (text/markdown) | /tmp/CHECKLIST.md]' }], - }, - }, 'final', 'run-4'); - - handleRuntimeEventState(h.set as never, h.get as never, { - message: { - role: 'assistant', - id: 'final-assistant', - content: [{ type: 'text', text: 'Done.' }], - }, - }, 'final', 'run-4'); - - expect(h.read().messages).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'final-assistant', - _attachedFiles: [ - expect.objectContaining({ - filePath: '/tmp/CHECKLIST.md', - source: 'tool-result', - }), - ], - }), - ])); - }); - - it('handles error event and finalizes immediately when not sending', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ sending: false, activeRunId: 'r1', lastUserMessageAt: 123 }); - - handleRuntimeEventState(h.set as never, h.get as never, { errorMessage: 'boom' }, 'error', 'r1'); - const next = h.read(); - expect(clearHistoryPoll).toHaveBeenCalledTimes(1); - expect(next.error).toBe('boom'); - expect(next.runError).toBeNull(); - expect(next.sending).toBe(false); - expect(next.activeRunId).toBeNull(); - expect(next.lastUserMessageAt).toBeNull(); - expect(next.streamingTools).toEqual([]); - }); - - it('defers recoverable terminated errors while the run is still active', async () => { - scheduleRecoverableRuntimeError.mockImplementation(() => {}); - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ sending: true, activeRunId: 'run-term', lastUserMessageAt: 123, error: null }); - - handleRuntimeEventState(h.set as never, h.get as never, { errorMessage: 'terminated' }, 'error', 'run-term'); - - const next = h.read(); - expect(scheduleRecoverableRuntimeError).toHaveBeenCalledTimes(1); - expect(next.error).toBeNull(); - expect(next.runError).toBeNull(); - expect(next.sending).toBe(true); - expect(next.activeRunId).toBe('run-term'); - expect(clearHistoryPoll).not.toHaveBeenCalled(); - }); - - it('treats stopReason=error assistant finals as runtime errors', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ sending: true, activeRunId: 'run-err', lastUserMessageAt: 123 }); - - handleRuntimeEventState(h.set as never, h.get as never, { - message: { - role: 'assistant', - id: 'assistant-error', - content: [], - stopReason: 'error', - errorMessage: '404 Resource not found', - }, - }, 'final', 'run-err'); - - const next = h.read(); - expect(next.error).toBeNull(); - expect(next.runError).toBe('404 Resource not found'); - expect(next.pendingFinal).toBe(false); - expect(next.streamingMessage).toBeNull(); - expect(clearHistoryPoll).toHaveBeenCalledTimes(1); - expect(scheduleRecoverableRuntimeError).not.toHaveBeenCalled(); - }); - - it('delta with empty object does not overwrite existing streamingMessage', async () => { - // Regression test for multi-model fallback: Gateway emits {} during model switch. - // The existing streamingMessage content must be preserved. - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const existing = { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }; - const h = makeHarness({ streamingMessage: existing }); - - handleRuntimeEventState(h.set as never, h.get as never, { message: {} }, 'delta', 'run-x'); - expect(h.read().streamingMessage).toEqual(existing); - }); - - it('delta with role-only object does not overwrite existing streamingMessage', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const existing = { role: 'assistant', content: [{ type: 'text', text: 'partial' }] }; - const h = makeHarness({ streamingMessage: existing }); - - handleRuntimeEventState(h.set as never, h.get as never, { message: { role: 'assistant' } }, 'delta', 'run-x'); - expect(h.read().streamingMessage).toEqual(existing); - }); - - it('delta with empty object is accepted when streamingMessage is null (initial state)', async () => { - // When streaming hasn't started yet, even an empty delta should be let - // through so the UI can show a typing indicator immediately. - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ streamingMessage: null }); - - handleRuntimeEventState(h.set as never, h.get as never, { message: { role: 'assistant' } }, 'delta', 'run-x'); - expect(h.read().streamingMessage).toEqual({ role: 'assistant' }); - }); - - it('delta with actual content replaces streamingMessage', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const existing = { role: 'assistant', content: [{ type: 'text', text: 'old' }] }; - const incoming = { role: 'assistant', content: [{ type: 'text', text: 'new' }] }; - const h = makeHarness({ streamingMessage: existing }); - - handleRuntimeEventState(h.set as never, h.get as never, { message: incoming }, 'delta', 'run-x'); - expect(h.read().streamingMessage).toEqual(incoming); - }); - - it('normalizes cumulative text and thinking blocks while streaming', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ streamingMessage: null }); - normalizeStreamingMessage.mockReturnValue({ - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'thinking 1 2 3' }, - { type: 'text', text: '1 2 3' }, - ], - }); - - handleRuntimeEventState(h.set as never, h.get as never, { - message: { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'thinking 1' }, - { type: 'thinking', thinking: 'thinking 1 2' }, - { type: 'thinking', thinking: 'thinking 1 2 3' }, - { type: 'text', text: '1' }, - { type: 'text', text: '1 2' }, - { type: 'text', text: '1 2 3' }, - ], - }, - }, 'delta', 'run-stream'); - - expect(h.read().streamingMessage).toEqual({ - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'thinking 1 2 3' }, - { type: 'text', text: '1 2 3' }, - ], - }); - }); - - it('snapshots normalized streaming content when tool results arrive', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - normalizeStreamingMessage.mockImplementation((message: unknown) => { - const msg = message as { role: string; id: string; content: unknown[] }; - return { - ...msg, - content: [ - { type: 'thinking', thinking: 'thinking 1 2 3' }, - { type: 'tool_use', id: 'call-1', name: 'read', input: { filePath: '/tmp/demo.md' } }, - { type: 'text', text: '1 2 3' }, - ], - }; - }); - snapshotStreamingAssistantMessage.mockImplementation((currentStream: unknown) => { - const msg = currentStream as { role: string; id: string; content: unknown[] }; - return [{ - ...msg, - content: [ - { type: 'thinking', thinking: 'thinking 1 2 3' }, - { type: 'tool_use', id: 'call-1', name: 'read', input: { filePath: '/tmp/demo.md' } }, - { type: 'text', text: '1 2 3' }, - ], - }]; - }); - const h = makeHarness({ - streamingMessage: { - role: 'assistant', - id: 'streaming-assistant', - content: [ - { type: 'thinking', thinking: 'thinking 1' }, - { type: 'thinking', thinking: 'thinking 1 2 3' }, - { type: 'tool_use', id: 'call-1', name: 'read', input: { filePath: '/tmp/demo.md' } }, - { type: 'text', text: '1' }, - { type: 'text', text: '1 2 3' }, - ], - }, - }); - - handleRuntimeEventState(h.set as never, h.get as never, { - message: { - role: 'toolResult', - toolCallId: 'call-1', - toolName: 'read', - content: [{ type: 'text', text: 'done' }], - }, - }, 'final', 'run-normalize'); - - expect(h.read().messages).toEqual([ - { - role: 'assistant', - id: 'streaming-assistant', - content: [ - { type: 'thinking', thinking: 'thinking 1 2 3' }, - { type: 'tool_use', id: 'call-1', name: 'read', input: { filePath: '/tmp/demo.md' } }, - { type: 'text', text: '1 2 3' }, - ], - }, - ]); - }); - - it('clears runtime state on aborted event', async () => { - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ - sending: true, - activeRunId: 'r2', - streamingText: 'abc', - pendingFinal: true, - lastUserMessageAt: 5, - pendingToolImages: [{ fileName: 'x' }], - }); - - handleRuntimeEventState(h.set as never, h.get as never, {}, 'aborted', 'r2'); - const next = h.read(); - expect(next.sending).toBe(false); - expect(next.activeRunId).toBeNull(); - expect(next.streamingText).toBe(''); - expect(next.pendingFinal).toBe(false); - expect(next.lastUserMessageAt).toBeNull(); - expect(next.pendingToolImages).toEqual([]); - }); - - it('filters out NO_REPLY internal message in final event without adding to messages', async () => { - isInternalMessage.mockReturnValueOnce(true); - const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); - const h = makeHarness({ - sending: true, - activeRunId: 'r3', - messages: [{ role: 'user', content: 'hello', id: 'u1' }], - }); - - handleRuntimeEventState( - h.set as never, - h.get as never, - { message: { role: 'assistant', content: 'NO_REPLY', id: 'a1' } }, - 'final', - 'r3', - ); - const next = h.read(); - // NO_REPLY must not appear in messages - expect(next.messages).toEqual([{ role: 'user', content: 'hello', id: 'u1' }]); - expect(next.sending).toBe(false); - expect(next.activeRunId).toBeNull(); - expect(next.pendingFinal).toBe(false); - expect(next.streamingText).toBe(''); - expect(next.streamingMessage).toBeNull(); - // Should trigger history reload - expect(clearHistoryPoll).toHaveBeenCalled(); - expect(next.loadHistory).toHaveBeenCalledWith(true); - }); -}); diff --git a/tests/unit/chat-session-actions.test.ts b/tests/unit/chat-session-actions.test.ts deleted file mode 100644 index c9b3401f..00000000 --- a/tests/unit/chat-session-actions.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const gatewayRpcMock = vi.fn(); -const sessionDeleteMock = vi.fn(); -const sessionRenameMock = vi.fn(); - -vi.mock('@/lib/host-api', () => ({ - hostApi: { - gateway: { - rpc: async (method: string, params?: unknown, timeoutMs?: number) => { - const result = await gatewayRpcMock(method, params, timeoutMs) as { - success?: boolean; - result?: unknown; - error?: string; - }; - if (result?.success === false) { - throw new Error(result.error || `RPC ${method} failed`); - } - return result?.result; - }, - }, - sessions: { - delete: (id: string) => sessionDeleteMock(id), - rename: (id: string, title: string) => sessionRenameMock(id, title), - }, - }, -})); - -type ChatLikeState = { - currentSessionKey: string; - sessions: Array<{ - key: string; - label?: string; - displayName?: string; - derivedTitle?: string; - lastMessagePreview?: string; - updatedAt?: number; - status?: string; - hasActiveRun?: boolean; - createdLocally?: boolean; - workspacePath?: string; - }>; - messages: Array<{ role: string; timestamp?: number; content?: unknown }>; - sessionLabels: Record; - sessionLastActivity: Record; - sending: boolean; - streamingText: string; - streamingMessage: unknown | null; - streamingTools: unknown[]; - activeRunId: string | null; - error: string | null; - runError: string | null; - pendingFinal: boolean; - lastUserMessageAt: number | null; - pendingToolImages: unknown[]; - loadHistory: ReturnType; -}; - -function makeHarness(initial?: Partial) { - let state: ChatLikeState = { - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, - error: null, - runError: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - loadHistory: vi.fn(), - ...initial, - }; - const set = (partial: Partial | ((s: ChatLikeState) => Partial)) => { - const patch = typeof partial === 'function' ? partial(state) : partial; - state = { ...state, ...patch }; - }; - const get = () => state; - return { set, get, read: () => state }; -} - -describe('chat session actions', () => { - beforeEach(() => { - vi.resetAllMocks(); - gatewayRpcMock.mockResolvedValue({ success: true }); - sessionDeleteMock.mockResolvedValue({ success: true }); - sessionRenameMock.mockResolvedValue({ success: true }); - }); - - it('switchSession preserves non-main session that has activity history', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:foo:session-a', - sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }], - messages: [], - sessionLabels: { 'agent:foo:session-a': 'A' }, - sessionLastActivity: { 'agent:foo:session-a': 1 }, - }); - const actions = createSessionActions(h.set as never, h.get as never); - - actions.switchSession('agent:foo:main'); - const next = h.read(); - expect(next.currentSessionKey).toBe('agent:foo:main'); - // Session with labels and activity should NOT be removed even though messages is empty, - // because messages get cleared eagerly during switchSession before loadHistory completes. - expect(next.sessions.find((s) => s.key === 'agent:foo:session-a')).toBeDefined(); - expect(next.sessionLabels['agent:foo:session-a']).toBe('A'); - expect(next.sessionLastActivity['agent:foo:session-a']).toBe(1); - expect(h.read().loadHistory).toHaveBeenCalledTimes(1); - }); - - it('switchSession removes truly empty non-main session (no activity, no labels)', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:foo:session-b', - sessions: [{ key: 'agent:foo:session-b' }, { key: 'agent:foo:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - }); - const actions = createSessionActions(h.set as never, h.get as never); - - actions.switchSession('agent:foo:main'); - const next = h.read(); - expect(next.currentSessionKey).toBe('agent:foo:main'); - // Truly empty session (no labels, no activity) should be cleaned up - expect(next.sessions.find((s) => s.key === 'agent:foo:session-b')).toBeUndefined(); - expect(h.read().loadHistory).toHaveBeenCalledTimes(1); - }); - - it('deleteSession updates current session and keeps sidebar consistent', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:foo:session-a', - sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }], - sessionLabels: { 'agent:foo:session-a': 'A' }, - sessionLastActivity: { 'agent:foo:session-a': 1 }, - messages: [{ role: 'user' }], - }); - const actions = createSessionActions(h.set as never, h.get as never); - - await actions.deleteSession('agent:foo:session-a'); - const next = h.read(); - expect(sessionDeleteMock).toHaveBeenCalledWith('agent:foo:session-a'); - expect(next.currentSessionKey).toBe('agent:foo:main'); - expect(next.sessions.map((s) => s.key)).toEqual(['agent:foo:main']); - expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined(); - expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined(); - expect(h.read().loadHistory).toHaveBeenCalledTimes(1); - }); - - it('newSession creates a canonical session key and clears transient state', async () => { - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111); - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:foo:main', - sessions: [{ key: 'agent:foo:main' }], - messages: [{ role: 'assistant' }], - streamingText: 'streaming', - activeRunId: 'r1', - pendingFinal: true, - }); - const actions = createSessionActions(h.set as never, h.get as never); - - actions.newSession(); - const next = h.read(); - expect(next.currentSessionKey).toBe('agent:foo:session-1711111111111'); - expect(next.sessions.find((s) => s.key === 'agent:foo:session-1711111111111')?.createdLocally).toBe(true); - expect(next.messages).toEqual([]); - expect(next.streamingText).toBe(''); - expect(next.activeRunId).toBeNull(); - expect(next.pendingFinal).toBe(false); - - actions.acknowledgeAcpSessionCreated( - 'agent:foo:session-1711111111111', - undefined, - 'Investigate the sidebar title race', - ); - expect(h.read().sessions.find((s) => s.key === 'agent:foo:session-1711111111111')?.createdLocally).toBe(false); - expect(h.read().sessionLabels['agent:foo:session-1711111111111']).toBe('Investigate the sidebar title race'); - nowSpy.mockRestore(); - }); - - it('seeds sessionLastActivity from backend updatedAt metadata', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - sessions: [], - }); - const actions = createSessionActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - sessions: [ - { - key: 'agent:main:main', - displayName: 'Main', - updatedAt: 1773281700000, - }, - { - key: 'agent:main:cron:job-1', - label: 'Cron: Drink water', - updatedAt: 1773281731621, - }, - ], - }, - }); - - await actions.loadSessions(); - - expect(h.read().sessionLastActivity['agent:main:main']).toBe(1773281700000); - expect(h.read().sessionLastActivity['agent:main:cron:job-1']).toBe(1773281731621); - expect(h.read().sessions.find((session) => session.key === 'agent:main:cron:job-1')?.updatedAt).toBe(1773281731621); - }); - - it('falls back from a missing current session unless it is locally pending', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:session-pending', - sessions: [], - messages: [], - }); - const actions = createSessionActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - sessions: [{ - key: 'agent:main:session-a', - displayName: 'Visible chat', - updatedAt: 1773281700000, - }], - }, - }); - - await actions.loadSessions(); - - const next = h.read(); - expect(next.currentSessionKey).toBe('agent:main:session-a'); - expect(next.sessions.map((session) => session.key)).toEqual(['agent:main:session-a']); - }); - - it('preserves a locally-created current session across session refreshes before first send', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const pendingKey = 'agent:main:session-1711111111111'; - const h = makeHarness({ - currentSessionKey: pendingKey, - sessions: [{ key: pendingKey, displayName: pendingKey, createdLocally: true }], - messages: [], - }); - const actions = createSessionActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - sessions: [ - { - key: pendingKey, - displayName: 'ACP', - updatedAt: 1773281800000, - }, - { - key: 'agent:main:session-a', - displayName: 'Visible chat', - updatedAt: 1773281700000, - }, - ], - }, - }); - - await actions.loadSessions(); - - const next = h.read(); - const pendingSession = next.sessions.find((session) => session.key === pendingKey); - expect(next.currentSessionKey).toBe(pendingKey); - expect(pendingSession?.createdLocally).toBe(true); - }); - - it('loadSessions preserves mirrored workspacePath when backend returns the same key without cwd', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:session-a', - sessions: [{ key: 'agent:main:session-a', workspacePath: '/Users/alex/workspace/ClawX' }], - messages: [], - }); - const actions = createSessionActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - sessions: [{ - key: 'agent:main:session-a', - displayName: 'Visible chat', - updatedAt: 1773281700000, - }], - }, - }); - - await actions.loadSessions(); - - expect(h.read().sessions.find((session) => session.key === 'agent:main:session-a')?.workspacePath) - .toBe('/Users/alex/workspace/ClawX'); - }); - - it('moves away from a heartbeat-only current session during sessions load', async () => { - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111); - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - sessions: [], - messages: [], - }); - const actions = createSessionActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - sessions: [{ - key: 'agent:main:main', - displayName: 'ClawX', - lastMessagePreview: '[OpenClaw heartbeat poll]', - updatedAt: 1773281700000, - }], - }, - }); - - await actions.loadSessions(); - - const next = h.read(); - expect(next.currentSessionKey).toBe('agent:main:session-1711111111111'); - expect(next.sessions.map((session) => session.key)).toEqual(['agent:main:session-1711111111111']); - expect(next.sessions[0]?.createdLocally).toBe(true); - expect(next.sessions.find((session) => session.key === 'agent:main:main')).toBeUndefined(); - nowSpy.mockRestore(); - }); - - it('keeps the fresh replacement when visible sessions could otherwise be fallback candidates', async () => { - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111); - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - sessions: [], - messages: [{ role: 'assistant', content: 'stale visible content' }], - streamingText: 'stale stream', - streamingMessage: { role: 'assistant', content: 'stale stream' }, - streamingTools: [{ name: 'tool', status: 'running' }], - sending: true, - activeRunId: 'run-stale', - error: 'stale error', - runError: 'old run error', - pendingFinal: true, - lastUserMessageAt: 1773281600000, - pendingToolImages: [{ fileName: 'stale.png' }], - }); - const actions = createSessionActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - sessions: [ - { - key: 'agent:main:main', - displayName: 'ClawX', - lastMessagePreview: '[OpenClaw heartbeat poll]', - updatedAt: 1773281700000, - }, - { - key: 'agent:main:session-1710000000000', - displayName: 'ClawX', - lastMessagePreview: 'Summarize the repository structure', - updatedAt: 1773281800000, - }, - ], - }, - }); - - await actions.loadSessions(); - - const next = h.read(); - expect(next.currentSessionKey).toBe('agent:main:session-1711111111111'); - expect(next.currentSessionKey).not.toBe('agent:main:session-1710000000000'); - expect(next.sessions.map((session) => session.key)).toEqual([ - 'agent:main:session-1710000000000', - 'agent:main:session-1711111111111', - ]); - expect( - next.sessions.find((session) => session.key === 'agent:main:session-1711111111111')?.createdLocally, - ).toBe(true); - expect(next.messages).toEqual([]); - expect(next.streamingText).toBe(''); - expect(next.streamingMessage).toBeNull(); - expect(next.streamingTools).toEqual([]); - expect(next.sending).toBe(false); - expect(next.activeRunId).toBeNull(); - expect(next.error).toBeNull(); - expect(next.runError).toBeNull(); - expect(next.pendingFinal).toBe(false); - expect(next.lastUserMessageAt).toBeNull(); - expect(next.pendingToolImages).toEqual([]); - nowSpy.mockRestore(); - }); - - it('clears stale current-run state when sessions.list reports the current session is idle', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - sending: true, - activeRunId: 'run-stale', - pendingFinal: true, - lastUserMessageAt: 1779693769991, - streamingText: 'partial', - streamingMessage: { role: 'assistant', content: 'partial' }, - streamingTools: [{ name: 'browser', status: 'completed' }], - pendingToolImages: [{ fileName: 'x.png' }], - }); - const actions = createSessionActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - sessions: [{ - key: 'agent:main:main', - displayName: 'Main', - updatedAt: 1779694521057, - status: 'done', - hasActiveRun: false, - }], - }, - }); - - await actions.loadSessions(); - - const next = h.read(); - expect(next.sending).toBe(false); - expect(next.activeRunId).toBeNull(); - expect(next.pendingFinal).toBe(false); - expect(next.lastUserMessageAt).toBeNull(); - expect(next.streamingText).toBe(''); - expect(next.streamingMessage).toBeNull(); - expect(next.streamingTools).toEqual([]); - expect(next.pendingToolImages).toEqual([]); - }); - - it('does not clear current-run state from stale sessions.list metadata older than the send', async () => { - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const h = makeHarness({ - currentSessionKey: 'agent:main:main', - sending: true, - activeRunId: 'run-current', - pendingFinal: true, - lastUserMessageAt: 2000, - }); - const actions = createSessionActions(h.set as never, h.get as never); - - gatewayRpcMock.mockResolvedValueOnce({ - success: true, - result: { - sessions: [{ - key: 'agent:main:main', - updatedAt: 1000, - status: 'done', - hasActiveRun: false, - }], - }, - }); - - await actions.loadSessions(); - - const next = h.read(); - expect(next.sending).toBe(true); - expect(next.activeRunId).toBe('run-current'); - expect(next.pendingFinal).toBe(true); - expect(next.lastUserMessageAt).toBe(2000); - }); -}); diff --git a/tests/unit/chat-session-management.test.ts b/tests/unit/chat-session-management.test.ts new file mode 100644 index 00000000..e30f787c --- /dev/null +++ b/tests/unit/chat-session-management.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { gatewayRpcMock, sessionDeleteMock, sessionRenameMock } = vi.hoisted(() => ({ + gatewayRpcMock: vi.fn(), + sessionDeleteMock: vi.fn(), + sessionRenameMock: vi.fn(), +})); + +vi.mock('@/stores/gateway', () => ({ + useGatewayStore: { getState: () => ({ rpc: gatewayRpcMock }) }, +})); + +vi.mock('@/lib/host-api', () => ({ + hostApi: { + sessions: { + summaries: vi.fn(async () => ({ success: true, summaries: [] })), + delete: sessionDeleteMock, + rename: sessionRenameMock, + }, + }, +})); + +describe('chat session management', () => { + beforeEach(() => { + vi.resetModules(); + window.localStorage.clear(); + gatewayRpcMock.mockReset(); + sessionDeleteMock.mockReset().mockResolvedValue({ success: true }); + sessionRenameMock.mockReset().mockResolvedValue({ success: true }); + }); + + it('drops only an abandoned local placeholder when switching sessions', async () => { + const localKey = 'agent:main:session-local'; + const persistedKey = 'agent:main:persisted'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: localKey, + currentAgentId: 'main', + sessions: [ + { key: localKey, createdLocally: true, workspacePath: '/draft' }, + { key: persistedKey, workspacePath: '/persisted' }, + ], + sessionLabels: { [localKey]: 'Draft' }, + sessionLastActivity: { [localKey]: 10 }, + }); + + useChatStore.getState().switchSession(persistedKey); + + expect(useChatStore.getState()).toMatchObject({ + currentSessionKey: persistedKey, + sessions: [{ key: persistedKey, workspacePath: '/persisted' }], + sessionLabels: {}, + sessionLastActivity: {}, + }); + + useChatStore.getState().switchSession('agent:main:other'); + expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({ key: persistedKey })); + }); + + it('keeps workspace identity when selecting and acknowledging an ACP session', async () => { + const key = 'agent:research:session-new'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }], + sessionLabels: {}, + sessionLastActivity: {}, + }); + + useChatStore.getState().selectAcpSession(key, '/workspace/research'); + expect(useChatStore.getState()).toMatchObject({ + currentSessionKey: key, + currentAgentId: 'research', + }); + expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({ + key, + workspacePath: '/workspace/research', + createdLocally: true, + })); + + useChatStore.getState().acknowledgeAcpSessionCreated(key, '/workspace/research', 'Investigate issue'); + + expect(useChatStore.getState().sessions.find((session) => session.key === key)).toMatchObject({ + workspacePath: '/workspace/research', + }); + expect(useChatStore.getState().sessions.find((session) => session.key === key)).not.toHaveProperty('createdLocally'); + expect(useChatStore.getState().sessionLabels[key]).toBe('Investigate issue'); + }); + + it('deletes an acknowledged session that is awaiting catalog confirmation', async () => { + const key = 'agent:research:session-created'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: key, + currentAgentId: 'research', + sessions: [{ key, createdLocally: true, workspacePath: '/workspace/research' }], + sessionLabels: {}, + sessionLastActivity: {}, + }); + useChatStore.getState().acknowledgeAcpSessionCreated(key, '/workspace/research'); + + await expect(useChatStore.getState().deleteSession(key)).resolves.toEqual({ success: true }); + + expect(useChatStore.getState().currentSessionKey).not.toBe(key); + expect(useChatStore.getState().sessions.some((session) => session.key === key)).toBe(false); + }); + + it('hard-deletes workspace sessions sequentially and selects a survivor', async () => { + let resolveFirst!: (value: { success: boolean }) => void; + sessionDeleteMock + .mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve; })) + .mockResolvedValueOnce({ success: true }); + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-a', + sessions: [ + { key: 'agent:main:session-a', workspacePath: '/missing' }, + { key: 'agent:other:session-b', workspacePath: '/missing' }, + { key: 'agent:main:session-c', workspacePath: '/kept' }, + ], + sessionLabels: { + 'agent:main:session-a': 'A', + 'agent:other:session-b': 'B', + 'agent:main:session-c': 'C', + }, + sessionLastActivity: {}, + }); + + const deletion = useChatStore.getState().deleteSessions([ + 'agent:main:session-a', + 'agent:other:session-b', + ]); + await vi.waitFor(() => expect(sessionDeleteMock).toHaveBeenCalledTimes(1)); + resolveFirst({ success: true }); + + await expect(deletion).resolves.toEqual({ + deletedKeys: ['agent:main:session-a', 'agent:other:session-b'], + failedKeys: [], + }); + expect(sessionDeleteMock.mock.calls.map(([key]) => key)).toEqual([ + 'agent:main:session-a', + 'agent:other:session-b', + ]); + expect(useChatStore.getState()).toMatchObject({ + currentSessionKey: 'agent:main:session-c', + currentAgentId: 'main', + sessionLabels: { 'agent:main:session-c': 'C' }, + }); + }); + + it('creates a local placeholder instead of selecting cron or channel survivors after bulk delete', async () => { + const currentKey = 'agent:research:session-current'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: currentKey, + currentAgentId: 'research', + sessions: [ + { key: currentKey }, + { key: 'agent:main:cron:heartbeat', updatedAt: 9_000 }, + { key: 'agent:main:feishu:ou_abc', updatedAt: 8_000 }, + ], + sessionLabels: { [currentKey]: 'Delete me' }, + sessionLastActivity: { [currentKey]: 123 }, + }); + + await expect(useChatStore.getState().deleteSessions([currentKey])).resolves.toEqual({ + deletedKeys: [currentKey], + failedKeys: [], + }); + + const state = useChatStore.getState(); + expect(state).toMatchObject({ + currentAgentId: 'research', + sessionLabels: {}, + sessionLastActivity: {}, + }); + expect(state.currentSessionKey).toMatch(/^agent:research:session-\d+$/); + expect(state.sessions).toEqual([ + { key: 'agent:main:cron:heartbeat', updatedAt: 9_000 }, + { key: 'agent:main:feishu:ou_abc', updatedAt: 8_000 }, + expect.objectContaining({ key: state.currentSessionKey, createdLocally: true }), + ]); + }); + + it('creates a local placeholder when bulk delete leaves no survivors', async () => { + const currentKey = 'agent:research:session-current'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: currentKey, + currentAgentId: 'research', + sessions: [{ key: currentKey }], + sessionLabels: { [currentKey]: 'Delete me' }, + sessionLastActivity: { [currentKey]: 123 }, + }); + + await expect(useChatStore.getState().deleteSessions([currentKey])).resolves.toEqual({ + deletedKeys: [currentKey], + failedKeys: [], + }); + + const state = useChatStore.getState(); + expect(state).toMatchObject({ + currentAgentId: 'research', + sessionLabels: {}, + sessionLastActivity: {}, + }); + expect(state.currentSessionKey).toMatch(/^agent:research:session-\d+$/); + expect(state.sessions).toEqual([ + expect.objectContaining({ key: state.currentSessionKey, createdLocally: true }), + ]); + expect(state.currentSessionKey).not.toBe('agent:main:main'); + }); + + it('keeps sessions whose hard delete fails', async () => { + sessionDeleteMock + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: 'locked' }); + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-c', + sessions: [ + { key: 'agent:main:session-a' }, + { key: 'agent:main:session-b' }, + { key: 'agent:main:session-c' }, + ], + sessionLabels: { + 'agent:main:session-a': 'A', + 'agent:main:session-b': 'B', + 'agent:main:session-c': 'C', + }, + sessionLastActivity: {}, + }); + + await expect(useChatStore.getState().deleteSessions([ + 'agent:main:session-a', + 'agent:main:session-b', + ])).resolves.toEqual({ + deletedKeys: ['agent:main:session-a'], + failedKeys: ['agent:main:session-b'], + }); + expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([ + 'agent:main:session-b', + 'agent:main:session-c', + ]); + }); + + it('cleans persisted attention after a successful hard delete', async () => { + const deletedKey = 'agent:main:delete-me'; + const { useChatStore } = await import('@/stores/chat'); + const { useSessionAttentionStore } = await import('@/stores/session-attention'); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }, { key: deletedKey }], + }); + useSessionAttentionStore.setState({ + bySessionKey: { [deletedKey]: { observedBusy: false, unread: true } }, + visibleSessionKey: null, + }); + + await expect(useChatStore.getState().deleteSession(deletedKey)).resolves.toEqual({ success: true }); + + expect(useSessionAttentionStore.getState().bySessionKey[deletedKey]).toBeUndefined(); + expect(window.localStorage.getItem('clawx.session-attention')).not.toContain(deletedKey); + }); + + it('retains session state when a single hard delete reports failure', async () => { + const key = 'agent:main:delete-failed'; + sessionDeleteMock.mockResolvedValueOnce({ success: false, error: 'locked' }); + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: key, + currentAgentId: 'main', + sessions: [{ key, workspacePath: '/workspace' }], + sessionLabels: { [key]: 'Keep me' }, + sessionLastActivity: { [key]: 123 }, + }); + + await expect(useChatStore.getState().deleteSession(key)).resolves.toEqual({ + success: false, + error: 'locked', + }); + expect(useChatStore.getState()).toMatchObject({ + currentSessionKey: key, + currentAgentId: 'main', + sessions: [{ key, workspacePath: '/workspace' }], + sessionLabels: { [key]: 'Keep me' }, + sessionLastActivity: { [key]: 123 }, + }); + }); + + it('retains session state when a single hard delete throws', async () => { + const key = 'agent:research:delete-threw'; + sessionDeleteMock.mockRejectedValueOnce(new Error('offline')); + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: key, + currentAgentId: 'research', + sessions: [{ key }], + sessionLabels: { [key]: 'Keep me too' }, + sessionLastActivity: { [key]: 456 }, + }); + + await expect(useChatStore.getState().deleteSession(key)).resolves.toEqual({ + success: false, + error: 'Error: offline', + }); + expect(useChatStore.getState()).toMatchObject({ + currentSessionKey: key, + currentAgentId: 'research', + sessions: [{ key }], + sessionLabels: { [key]: 'Keep me too' }, + sessionLastActivity: { [key]: 456 }, + }); + }); + + it('persists a rename and updates the catalog label', async () => { + const key = 'agent:main:rename-me'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + sessions: [{ key, label: 'Old' }], + sessionLabels: { [key]: 'Old' }, + sessionLastActivity: {}, + }); + + await useChatStore.getState().renameSession(key, ' New title '); + + expect(sessionRenameMock).toHaveBeenCalledWith(key, 'New title'); + expect(useChatStore.getState().sessions[0]?.label).toBe('New title'); + expect(useChatStore.getState().sessionLabels[key]).toBe('New title'); + }); +}); diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts deleted file mode 100644 index 4228d955..00000000 --- a/tests/unit/chat-store-history-retry.test.ts +++ /dev/null @@ -1,2060 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { chatHistoryRpcParams } from './gateway-rpc-test-utils'; - -const { gatewayRpcMock, agentsState, hostApiFetchMock, sessionDeleteMock } = vi.hoisted(() => ({ - gatewayRpcMock: vi.fn(), - agentsState: { - agents: [] as Array>, - }, - hostApiFetchMock: vi.fn(), - sessionDeleteMock: vi.fn(), -})); - -vi.mock('@/stores/gateway', () => ({ - useGatewayStore: { - getState: () => ({ - status: { state: 'running', port: 18789, connectedAt: Date.now() }, - rpc: gatewayRpcMock, - }), - }, -})); - -vi.mock('@/stores/agents', () => ({ - useAgentsStore: { - getState: () => agentsState, - }, -})); - -vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), - hostApi: { - media: { - thumbnails: vi.fn(async () => ({})), - }, - sessions: { - history: async (input: { sessionKey?: string; agentId?: string; sessionId?: string; limit?: number }) => { - if (input?.sessionKey) { - const params = new URLSearchParams(); - params.set('sessionKey', input.sessionKey); - params.set('limit', String(input.limit ?? 200)); - return hostApiFetchMock(`/api/sessions/transcript?${params.toString()}`); - } - const params = new URLSearchParams(); - if (input?.agentId) params.set('agentId', input.agentId); - if (input?.sessionId) params.set('sessionId', input.sessionId); - if (input?.limit) params.set('limit', String(input.limit)); - return hostApiFetchMock(`/api/sessions/transcript?${params.toString()}`); - }, - summaries: async (input: unknown) => hostApiFetchMock('/api/sessions/summaries', { - method: 'POST', - body: JSON.stringify(input), - }), - delete: (key: string) => sessionDeleteMock(key), - rename: vi.fn(async () => ({ success: true })), - }, - chat: { - sendWithMedia: vi.fn(async () => ({ success: true, result: { runId: 'run-media' } })), - }, - }, -})); - -describe('useChatStore startup history retry', () => { - beforeEach(async () => { - vi.resetModules(); - vi.useFakeTimers(); - window.localStorage.clear(); - agentsState.agents = []; - gatewayRpcMock.mockReset(); - hostApiFetchMock.mockReset(); - sessionDeleteMock.mockReset(); - sessionDeleteMock.mockResolvedValue({ success: true }); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/chat/sessions' || path === '/api/chat/history' || path === '/api/chat/send' || path === '/api/chat/abort') { - throw new Error('No route for mocked chat host API'); - } - return { messages: [] }; - }); - const { resetChatHistoryMaxCharsCache, resolveChatHistoryMaxChars } = await import('@/stores/chat/history-rpc-params'); - resetChatHistoryMaxCharsCache(); - await resolveChatHistoryMaxChars(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('uses the longer timeout only for the initial foreground history load', async () => { - const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'first load', timestamp: 1000 }], - }) - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'quiet refresh', timestamp: 1001 }], - }); - - await useChatStore.getState().loadHistory(false); - vi.advanceTimersByTime(1_000); - await useChatStore.getState().loadHistory(true); - - expect(gatewayRpcMock).toHaveBeenNthCalledWith( - 1, - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - 35_000, - ); - expect(gatewayRpcMock).toHaveBeenNthCalledWith( - 2, - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - undefined, - ); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800); - setTimeoutSpy.mockRestore(); - }); - - it('strips ACP working-directory metadata from a non-main history-derived label', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-a', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-a' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - gatewayRpcMock.mockResolvedValueOnce({ - messages: [{ - role: 'user', - content: '[Working directory: ~/.openclaw/workspace]\n\nHistory prompt', - timestamp: 1000, - }], - }); - - await useChatStore.getState().loadHistory(false); - - expect(useChatStore.getState().sessionLabels['agent:main:session-a']).toBe('History prompt'); - }); - - it('keeps an explicit non-main label when history and summaries provide automatic titles', async () => { - const sessionKey = 'agent:main:manual-label'; - const manualLabel = '[Working directory: /user-chosen]\n This deliberately long manual title looks like a marker and must remain exactly as entered. '; - const summaryTimestamp = 2000; - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: sessionKey, - currentAgentId: 'main', - sessions: [{ key: sessionKey, label: manualLabel }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - gatewayRpcMock.mockResolvedValueOnce({ - messages: [{ - role: 'user', - content: '[Working directory: ~/.openclaw/workspace]\n\nHistory-derived automatic title', - timestamp: 1000, - }], - }); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/sessions/summaries') { - return { - success: true, - summaries: [{ - sessionKey, - firstUserText: '[Working directory: ~/.openclaw/workspace]\n\nSummary-derived automatic title', - lastTimestamp: summaryTimestamp, - }], - }; - } - return { messages: [] }; - }); - - await useChatStore.getState().loadHistory(false); - await vi.waitFor(() => { - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/sessions/summaries', { - method: 'POST', - body: JSON.stringify({ sessionKeys: [sessionKey] }), - }); - expect(useChatStore.getState().sessionLastActivity[sessionKey]).toBe(summaryTimestamp); - }); - - const state = useChatStore.getState(); - expect(state.sessionLabels[sessionKey]).toBeUndefined(); - expect(state.sessions.find((session) => session.key === sessionKey)?.label).toBe(manualLabel); - }); - - it('keeps an explicitly labeled empty source session when switching', async () => { - const sourceKey = 'agent:main:explicit-label-source'; - const targetKey = 'agent:main:target'; - const explicitLabel = '[Working directory: /user-chosen]\n This deliberately long manual title looks like a marker and must remain exactly as entered. '; - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: sourceKey, - currentAgentId: 'main', - sessions: [ - { key: sourceKey, label: explicitLabel }, - { key: targetKey }, - ], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - useChatStore.getState().switchSession(targetKey); - - expect(useChatStore.getState().sessions).toContainEqual({ key: sourceKey, label: explicitLabel }); - }); - - it('keeps an explicitly labeled empty current session during cleanup', async () => { - const sessionKey = 'agent:main:explicit-label-current'; - const explicitLabel = '[Working directory: /user-chosen]\n This deliberately long manual title looks like a marker and must remain exactly as entered. '; - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: sessionKey, - currentAgentId: 'main', - sessions: [{ key: sessionKey, label: explicitLabel }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - useChatStore.getState().cleanupEmptySession(); - - expect(useChatStore.getState().sessions).toContainEqual({ key: sessionKey, label: explicitLabel }); - }); - - it('renders local transcript fallback while the initial gateway history request is still pending', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - let resolveGatewayHistory: ((value: { messages: Array<{ role: string; content: string; timestamp: number }> }) => void) | null = null; - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'config.get') return {}; - if (method !== 'chat.history') { - throw new Error(`Unexpected gateway RPC: ${method}`); - } - return await new Promise<{ messages: Array<{ role: string; content: string; timestamp: number }> }>((resolve) => { - resolveGatewayHistory = resolve; - }); - }); - hostApiFetchMock.mockImplementation(async (url: string) => { - if (url.startsWith('/api/sessions/transcript')) { - return { - messages: [{ role: 'assistant', content: 'local transcript first', timestamp: 1000 }], - }; - } - return { messages: [] }; - }); - - const loadPromise = useChatStore.getState().loadHistory(false); - await vi.advanceTimersByTimeAsync(1_500); - - expect(useChatStore.getState().loading).toBe(false); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'local transcript first', - ]); - expect(gatewayRpcMock).toHaveBeenCalledWith( - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - 35_000, - ); - - const quietReloadWhileGatewayPending = useChatStore.getState().loadHistory(true); - await Promise.resolve(); - expect(gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history')).toHaveLength(1); - - resolveGatewayHistory?.({ - messages: [{ role: 'assistant', content: 'gateway authoritative history', timestamp: 1001 }], - }); - await loadPromise; - await quietReloadWhileGatewayPending; - await vi.waitFor(() => { - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'gateway authoritative history', - ]); - }); - }); - - it('keeps startup retry active after rendering local transcript fallback', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - let rejectFirstGatewayHistory: ((reason?: unknown) => void) | null = null; - let chatHistoryCalls = 0; - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'config.get') return {}; - if (method !== 'chat.history') { - throw new Error(`Unexpected gateway RPC: ${method}`); - } - chatHistoryCalls += 1; - if (chatHistoryCalls === 1) { - return await new Promise((_resolve, reject) => { - rejectFirstGatewayHistory = reject; - }); - } - return { - messages: [{ role: 'assistant', content: 'gateway history after retry', timestamp: 1001 }], - }; - }); - hostApiFetchMock.mockImplementation(async (url: string) => { - if (url.startsWith('/api/sessions/transcript')) { - return { - messages: [{ role: 'assistant', content: 'local transcript first', timestamp: 1000 }], - }; - } - return { messages: [] }; - }); - - const loadPromise = useChatStore.getState().loadHistory(false); - await vi.advanceTimersByTimeAsync(1_500); - - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'local transcript first', - ]); - - rejectFirstGatewayHistory?.(new Error('RPC timeout: chat.history')); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(800); - await loadPromise; - - expect(gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history')).toHaveLength(2); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'gateway history after retry', - ]); - }); - - it('keeps local transcript fallback when gateway returns empty history', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - let resolveGatewayHistory: ((value: { messages: Array }) => void) | null = null; - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'config.get') return {}; - if (method !== 'chat.history') { - throw new Error(`Unexpected gateway RPC: ${method}`); - } - return await new Promise<{ messages: Array }>((resolve) => { - resolveGatewayHistory = resolve; - }); - }); - hostApiFetchMock.mockImplementation(async (url: string) => { - if (url.startsWith('/api/sessions/transcript')) { - return { - messages: [{ role: 'assistant', content: 'local transcript remains', timestamp: 1000 }], - }; - } - return { messages: [] }; - }); - - const loadPromise = useChatStore.getState().loadHistory(false); - await vi.advanceTimersByTimeAsync(1_500); - - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'local transcript remains', - ]); - - resolveGatewayHistory?.({ messages: [] }); - await loadPromise; - - expect(useChatStore.getState().loading).toBe(false); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'local transcript remains', - ]); - }); - - it('forces the internal final-message reload through the quiet history cooldown', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock - .mockResolvedValueOnce({ - messages: [{ role: 'user', content: 'hello', id: 'u1', timestamp: 1000 }], - }) - .mockResolvedValueOnce({ - messages: [ - { role: 'user', content: 'hello', id: 'u1', timestamp: 1000 }, - { role: 'assistant', content: 'Real answer', id: 'a2', timestamp: 1001 }, - ], - }); - - await useChatStore.getState().loadHistory(true); - useChatStore.setState({ - sending: true, - activeRunId: 'run-internal', - streamingText: 'NO_REPLY', - streamingMessage: { role: 'assistant', content: 'NO_REPLY' }, - }); - - useChatStore.getState().handleChatEvent({ - state: 'final', - runId: 'run-internal', - sessionKey: 'agent:main:main', - message: { role: 'assistant', content: 'NO_REPLY', id: 'a1' }, - }); - - await vi.waitFor(() => { - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'hello', - 'Real answer', - ]); - }); - - const historyCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history'); - expect(historyCalls).toHaveLength(2); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'hello', - 'Real answer', - ]); - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - }); - - it('keeps non-startup foreground loading safety timeout at 15 seconds', async () => { - const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'first load', timestamp: 1000 }], - }) - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'second foreground load', timestamp: 1001 }], - }); - - await useChatStore.getState().loadHistory(false); - setTimeoutSpy.mockClear(); - await useChatStore.getState().loadHistory(false); - - expect(gatewayRpcMock).toHaveBeenNthCalledWith( - 2, - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - undefined, - ); - expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 15_000); - setTimeoutSpy.mockRestore(); - }); - - it('keeps cached session messages visible without foreground loading overlay during refresh', async () => { - const { useChatStore } = await import('@/stores/chat'); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'cached history', timestamp: 1000 }], - }) - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'main history', timestamp: 1001 }], - }); - - useChatStore.setState({ currentSessionKey: 'agent:main:other' }); - await useChatStore.getState().loadHistory(false); - - gatewayRpcMock.mockImplementationOnce(() => new Promise((resolve) => { - setTimeout(() => { - resolve({ messages: [{ role: 'assistant', content: 'refreshed cached history', timestamp: 1002 }] }); - }, 10); - })); - - useChatStore.getState().switchSession('agent:main:other'); - - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']); - expect(useChatStore.getState().loading).toBe(false); - }); - - it('switchSession restores cached session messages immediately while refreshing in background', async () => { - const { useChatStore } = await import('@/stores/chat'); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'cached history', timestamp: 1000 }], - }) - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'main history', timestamp: 1001 }], - }); - - useChatStore.setState({ currentSessionKey: 'agent:main:other' }); - await useChatStore.getState().loadHistory(false); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'refreshed cached history', timestamp: 1002 }], - }); - - useChatStore.getState().switchSession('agent:main:other'); - - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']); - }); - - it('does not re-arm Thinking state for stale main-session heartbeat tool history', async () => { - const { useChatStore } = await import('@/stores/chat'); - - gatewayRpcMock.mockResolvedValue({ - messages: [ - { id: 'user-old', role: 'user', content: 'old question', timestamp: 1_000 }, - { - id: 'assistant-tool', - role: 'assistant', - content: [{ type: 'toolCall', id: 'tool-1', name: 'read', arguments: { path: '~/.openclaw/workspace/HEARTBEAT.md' } }], - stopReason: 'toolUse', - timestamp: 1_100, - }, - ], - }); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - await useChatStore.getState().loadHistory(false); - - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - }); - - it('switchSession preserves unsynced optimistic user messages when switching back', async () => { - const { useChatStore } = await import('@/stores/chat'); - const optimisticHello = { - id: 'user-hello-opt', - role: 'user' as const, - content: '你好', - timestamp: 1_700_000_000, - }; - - useChatStore.setState({ - currentSessionKey: 'agent:main:session-a', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-a' }, { key: 'agent:main:other' }], - messages: [optimisticHello], - sessionLabels: {}, - sessionLastActivity: { 'agent:main:session-a': Date.now() }, - sending: true, - activeRunId: 'run-hello', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: Date.now(), - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValue({ messages: [] }); - - useChatStore.getState().switchSession('agent:main:other'); - useChatStore.getState().switchSession('agent:main:session-a'); - - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['你好']); - }); - - it('switchSession restores in-flight run state so Thinking indicator survives navigation', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-run', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-run' }, { key: 'agent:main:other' }], - messages: [ - { id: 'user-run', role: 'user', content: 'browse the page', timestamp: 1000 }, - { - id: 'assistant-tool-run', - role: 'assistant', - content: [ - { type: 'toolCall', id: 'tool-run', name: 'browser', input: { action: 'snapshot' } }, - ], - stopReason: 'toolUse', - timestamp: 1500, - }, - ], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-nav-test', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: true, - lastUserMessageAt: 1000, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - useChatStore.getState().switchSession('agent:main:other'); - expect(useChatStore.getState().sending).toBe(false); - - useChatStore.getState().switchSession('agent:main:session-run'); - - const state = useChatStore.getState(); - expect(state.sending).toBe(true); - expect(state.activeRunId).toBe('run-nav-test'); - expect(state.pendingFinal).toBe(true); - expect(state.lastUserMessageAt).toBe(1000); - }); - - it('treats the same session as a fresh foreground load after gateway runtime changes', async () => { - const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'first runtime', timestamp: 1000 }], - }) - .mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'second runtime', timestamp: 1001 }], - }); - - await useChatStore.getState().loadHistory(false); - - vi.resetModules(); - vi.doMock('@/stores/gateway', () => ({ - useGatewayStore: { - getState: () => ({ - status: { state: 'running', port: 18789, connectedAt: Date.now() + 5_000 }, - rpc: gatewayRpcMock, - }), - }, - })); - const { useChatStore: useChatStoreReloaded } = await import('@/stores/chat'); - useChatStoreReloaded.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - setTimeoutSpy.mockClear(); - await useChatStoreReloaded.getState().loadHistory(false); - - expect(gatewayRpcMock).toHaveBeenLastCalledWith( - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - 35_000, - ); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800); - setTimeoutSpy.mockRestore(); - }); - - it('does not burn the first-load retry path when the first attempt becomes stale', async () => { - vi.useRealTimers(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { useChatStore } = await import('@/stores/chat'); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - let resolveFirstAttempt: ((value: { messages: Array<{ role: string; content: string; timestamp: number }> }) => void) | null = null; - let historyAttempt = 0; - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'config.get') return {}; - if (method !== 'chat.history') { - throw new Error(`Unexpected gateway RPC: ${method}`); - } - historyAttempt += 1; - if (historyAttempt === 1) { - return await new Promise<{ messages: Array<{ role: string; content: string; timestamp: number }> }>((resolve) => { - resolveFirstAttempt = resolve; - }); - } - if (historyAttempt === 2) { - throw new Error('RPC timeout: chat.history'); - } - return { - messages: [{ role: 'assistant', content: 'restored after retry', timestamp: 1002 }], - }; - }); - - const firstLoad = useChatStore.getState().loadHistory(false); - await Promise.resolve(); - useChatStore.setState({ - currentSessionKey: 'agent:main:other', - messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }], - }); - resolveFirstAttempt?.({ - messages: [{ role: 'assistant', content: 'stale original payload', timestamp: 1000 }], - }); - await firstLoad; - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - messages: [], - }); - const secondLoad = useChatStore.getState().loadHistory(false); - await new Promise((resolve) => setTimeout(resolve, 900)); - await secondLoad; - vi.useFakeTimers(); - - const historyCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history'); - expect(historyCalls).toHaveLength(3); - expect(historyCalls[0]).toEqual([ - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - 35_000, - ]); - expect(historyCalls[1]).toEqual([ - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - 35_000, - ]); - expect(historyCalls[2]).toEqual([ - 'chat.history', - chatHistoryRpcParams('agent:main:main', 200), - 35_000, - ]); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['restored after retry']); - expect(warnSpy).toHaveBeenCalledWith( - '[chat.history] startup retry scheduled', - expect.objectContaining({ - sessionKey: 'agent:main:main', - attempt: 1, - }), - ); - warnSpy.mockRestore(); - }); - - it('stops retrying once the user switches sessions mid-load', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { useChatStore } = await import('@/stores/chat'); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockImplementationOnce(async () => { - useChatStore.setState({ - currentSessionKey: 'agent:main:other', - messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }], - loading: false, - }); - throw new Error('RPC timeout: chat.history'); - }); - - await useChatStore.getState().loadHistory(false); - - expect(gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history')).toHaveLength(1); - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:other'); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['other session']); - expect(useChatStore.getState().error).toBeNull(); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - it('keeps the optimistic user message when completion refresh wins the transcript write race', async () => { - const { useChatStore } = await import('@/stores/chat'); - let historyMessages: Array> = []; - let resolveSend: ((value: { runId: string }) => void) | null = null; - - gatewayRpcMock.mockImplementation((method: string) => { - if (method === 'chat.send') { - return new Promise((resolve) => { - resolveSend = resolve as (value: { runId: string }) => void; - }); - } - if (method === 'chat.history') { - return Promise.resolve({ messages: historyMessages }); - } - return Promise.resolve({}); - }); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - const sendPromise = useChatStore.getState().sendMessage('hello from app'); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']); - - // Simulate Gateway phase=end clearing send state before chat.history has - // persisted the user turn. - useChatStore.setState({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - }); - - await useChatStore.getState().loadHistory(true); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']); - - historyMessages = [{ - role: 'user', - content: 'hello from app', - timestamp: Date.now() / 1000, - id: 'server-user', - }]; - vi.advanceTimersByTime(1_000); - await useChatStore.getState().loadHistory(true); - - expect(useChatStore.getState().messages).toHaveLength(1); - expect(useChatStore.getState().messages[0]).toMatchObject({ - id: 'server-user', - role: 'user', - content: 'hello from app', - }); - - resolveSend?.({ runId: 'run-1' }); - await sendPromise; - }); - - it('uses raw first-prompt text for an optimistic non-main session label', async () => { - const sessionKey = 'agent:main:session-raw-title'; - const prompt = '[Working directory: /user-authored] Plan'; - const { useChatStore } = await import('@/stores/chat'); - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'chat.send') return { runId: 'run-raw-title' }; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - useChatStore.setState({ - currentSessionKey: sessionKey, - currentAgentId: 'main', - sessions: [{ key: sessionKey }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - await useChatStore.getState().sendMessage(prompt); - - expect(useChatStore.getState().sessionLabels[sessionKey]).toBe(prompt); - }); - - it('does not cache a first prompt for a non-main session with an explicit label', async () => { - const sessionKey = 'agent:main:session-manual-title'; - const manualLabel = '[Working directory: /user-chosen]\n This deliberately long manual title looks like a marker and must remain exactly as entered. '; - const { useChatStore } = await import('@/stores/chat'); - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'chat.send') return { runId: 'run-manual-title' }; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - useChatStore.setState({ - currentSessionKey: sessionKey, - currentAgentId: 'main', - sessions: [{ key: sessionKey, label: manualLabel }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - await useChatStore.getState().sendMessage('[Working directory: ~/.openclaw/workspace]\n\nAutomatic first prompt'); - - const state = useChatStore.getState(); - expect(state.sessions.find((session) => session.key === sessionKey)?.label).toBe(manualLabel); - expect(state.sessionLabels[sessionKey]).toBeUndefined(); - }); - - it('does not restore a pending optimistic message after deleting the session', async () => { - const { useChatStore } = await import('@/stores/chat'); - let resolveSend: ((value: { runId: string }) => void) | null = null; - - gatewayRpcMock.mockImplementation((method: string) => { - if (method === 'chat.send') { - return new Promise((resolve) => { - resolveSend = resolve as (value: { runId: string }) => void; - }); - } - if (method === 'chat.history') { - return Promise.resolve({ messages: [] }); - } - return Promise.resolve({}); - }); - hostApiFetchMock.mockImplementation((url: string) => { - if (url === '/api/sessions/delete') { - return Promise.resolve({ success: true }); - } - return Promise.resolve({ messages: [] }); - }); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - const sendPromise = useChatStore.getState().sendMessage('message that will be deleted'); - expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([ - 'message that will be deleted', - ]); - - await useChatStore.getState().deleteSession('agent:main:main'); - expect(useChatStore.getState().messages).toEqual([]); - - await useChatStore.getState().loadHistory(true); - expect(useChatStore.getState().messages).toEqual([]); - - resolveSend?.({ runId: 'run-deleted' }); - await sendPromise; - }); - - it('hard-deletes workspace sessions sequentially and switches once to a surviving session', async () => { - const { useChatStore } = await import('@/stores/chat'); - let resolveFirst!: (value: { success: boolean }) => void; - sessionDeleteMock - .mockReturnValueOnce(new Promise((resolve) => { - resolveFirst = resolve; - })) - .mockResolvedValueOnce({ success: true }); - gatewayRpcMock.mockResolvedValue({ messages: [] }); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-a', - currentAgentId: 'main', - sessions: [ - { key: 'agent:main:session-a', workspacePath: '/missing' }, - { key: 'agent:other:session-b', workspacePath: '/missing' }, - { key: 'agent:main:session-c', workspacePath: '/kept' }, - ], - sessionLabels: { - 'agent:main:session-a': 'A', - 'agent:other:session-b': 'B', - 'agent:main:session-c': 'C', - }, - sessionLastActivity: { - 'agent:main:session-a': 3, - 'agent:other:session-b': 2, - 'agent:main:session-c': 1, - }, - messages: [{ role: 'user', content: 'A' }], - }); - - const deletion = useChatStore.getState().deleteSessions([ - 'agent:main:session-a', - 'agent:other:session-b', - ]); - await vi.waitFor(() => expect(sessionDeleteMock).toHaveBeenCalledTimes(1)); - resolveFirst({ success: true }); - const result = await deletion; - - expect(sessionDeleteMock.mock.calls.map(([key]) => key)).toEqual([ - 'agent:main:session-a', - 'agent:other:session-b', - ]); - expect(result).toEqual({ - deletedKeys: ['agent:main:session-a', 'agent:other:session-b'], - failedKeys: [], - }); - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([ - 'agent:main:session-c', - ]); - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-c'); - expect(useChatStore.getState().sessionLabels).toEqual({ 'agent:main:session-c': 'C' }); - }); - - it('keeps sessions whose workspace-group hard delete fails', async () => { - const { useChatStore } = await import('@/stores/chat'); - sessionDeleteMock - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: false, error: 'locked' }); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-c', - currentAgentId: 'main', - sessions: [ - { key: 'agent:main:session-a', workspacePath: '/missing' }, - { key: 'agent:main:session-b', workspacePath: '/missing' }, - { key: 'agent:main:session-c', workspacePath: '/kept' }, - ], - sessionLabels: { - 'agent:main:session-a': 'A', - 'agent:main:session-b': 'B', - 'agent:main:session-c': 'C', - }, - sessionLastActivity: {}, - }); - - const result = await useChatStore.getState().deleteSessions([ - 'agent:main:session-a', - 'agent:main:session-b', - ]); - - expect(result).toEqual({ - deletedKeys: ['agent:main:session-a'], - failedKeys: ['agent:main:session-b'], - }); - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual([ - 'agent:main:session-b', - 'agent:main:session-c', - ]); - expect(useChatStore.getState().sessionLabels).toEqual({ - 'agent:main:session-b': 'B', - 'agent:main:session-c': 'C', - }); - }); - - // Regression for the "thinking disappears mid-tool-chain" bug: - // when the history-poll loads an intermediate `[thinking, toolCall]` assistant - // message (stop_reason=tool_use) the closer half of applyLoadedMessages used - // to match it as a "final reply" — because `hasNonToolAssistantContent` once - // counted thinking blocks as user-visible content — and clear sending / - // activeRunId / pendingFinal. That caused the Execution Graph card to flip to - // inactive, the Thinking… dot to vanish, and ChatInput's stop button to - // revert to a send button while the agent was still running tools. - it('keeps the run open across intermediate [thinking, toolCall] history snapshots', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-1', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-1' }], - messages: [ - { id: 'user-1', role: 'user', content: '帮我查一下昆明未来七天的天气', timestamp: 1000 }, - ], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-keep-open', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: 1000, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [ - { id: 'user-1', role: 'user', content: '帮我查一下昆明未来七天的天气', timestamp: 1000 }, - { - id: 'assistant-tool-1', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Let me search for the weather.' }, - { type: 'toolCall', id: 'tool-1', name: 'web_search', input: { query: 'Kunming weather' } }, - ], - stopReason: 'toolUse', - timestamp: 1500, - }, - ], - }); - - await useChatStore.getState().loadHistory(true); - - const state = useChatStore.getState(); - expect(state.sending).toBe(true); - expect(state.activeRunId).toBe('run-keep-open'); - expect(state.pendingFinal).toBe(false); - }); - - // Regression for the mixed `[thinking, text, toolCall]` shape some models - // emit. Even though it carries user-visible text, stop_reason=tool_use means - // the assistant is still pending a tool result and the lifecycle must stay - // armed. Without `hasPendingToolUse`, the closer would match this on the - // text block and clear sending. - it('keeps the run open for mixed [thinking, text, toolCall] turns with stopReason=toolUse', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-2', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-2' }], - messages: [ - { id: 'user-2', role: 'user', content: 'mixed turn test', timestamp: 2000 }, - ], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-mixed', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: true, - lastUserMessageAt: 2000, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [ - { id: 'user-2', role: 'user', content: 'mixed turn test', timestamp: 2000 }, - { - id: 'assistant-mixed-1', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'I should search before answering.' }, - { type: 'text', text: 'Let me search for that.' }, - { type: 'toolCall', id: 'tool-2', name: 'web_search', input: { query: 'foo' } }, - ], - stopReason: 'toolUse', - timestamp: 2500, - }, - ], - }); - - await useChatStore.getState().loadHistory(true); - - const state = useChatStore.getState(); - expect(state.sending).toBe(true); - expect(state.activeRunId).toBe('run-mixed'); - expect(state.pendingFinal).toBe(true); - }); - - // Positive case: a real final reply (text/image, no pending tool) SHOULD - // close the run when applyLoadedMessages observes it via history poll. - it('closes the run when a final assistant reply (text, stopReason=endTurn) appears', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-3', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-3' }], - messages: [ - { id: 'user-3', role: 'user', content: 'final reply test', timestamp: 3000 }, - ], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-final', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: true, - lastUserMessageAt: 3000, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [ - { id: 'user-3', role: 'user', content: 'final reply test', timestamp: 3000 }, - { - id: 'assistant-final-1', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'I have all the info.' }, - { type: 'text', text: 'Here is the answer.' }, - ], - stopReason: 'endTurn', - timestamp: 3500, - }, - ], - }); - - await useChatStore.getState().loadHistory(true); - - const state = useChatStore.getState(); - expect(state.sending).toBe(false); - expect(state.activeRunId).toBeNull(); - expect(state.pendingFinal).toBe(false); - }); - - it('unsticks sending when history has a final reply after tools without pendingFinal', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-stuck', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-stuck' }], - messages: [ - { id: 'user-stuck', role: 'user', content: '执行一下github1', timestamp: 1000 }, - ], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-stuck', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: 1000, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [ - { id: 'user-stuck', role: 'user', content: '执行一下github1', timestamp: 1000 }, - { - id: 'assistant-tool', - role: 'assistant', - content: [ - { type: 'text', text: 'Fetching data.' }, - { type: 'tool_use', id: 'tool-1', name: 'web_fetch', input: { url: 'https://example.com' } }, - ], - timestamp: 1500, - }, - { - id: 'assistant-final', - role: 'assistant', - content: [{ type: 'text', text: '执行完成 ✅' }], - stopReason: 'endTurn', - timestamp: 2000, - }, - ], - }); - - await useChatStore.getState().loadHistory(true); - - const state = useChatStore.getState(); - expect(state.sending).toBe(false); - expect(state.activeRunId).toBeNull(); - expect(state.pendingFinal).toBe(false); - }); - - // Cross-protocol coverage: Anthropic Messages API native shape (snake_case). - // OpenClaw's gateway normally normalizes to camelCase, but some paths can - // pass Anthropic responses through unchanged. `hasPendingToolUse` must still - // detect the intermediate turn via `stop_reason: "tool_use"` plus - // `content[].type === "tool_use"`. - it('keeps the run open for Anthropic-native [thinking, tool_use] (snake_case stop_reason)', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-anthropic', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-anthropic' }], - messages: [ - { id: 'user-a', role: 'user', content: 'anthropic protocol', timestamp: 4000 }, - ], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-anthropic', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: 4000, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [ - { id: 'user-a', role: 'user', content: 'anthropic protocol', timestamp: 4000 }, - { - id: 'assistant-anthropic-1', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Should I use a tool?' }, - { type: 'tool_use', id: 'toolu_01', name: 'web_search', input: { query: 'foo' } }, - ], - stop_reason: 'tool_use', - timestamp: 4500, - }, - ], - }); - - await useChatStore.getState().loadHistory(true); - - const state = useChatStore.getState(); - expect(state.sending).toBe(true); - expect(state.activeRunId).toBe('run-anthropic'); - }); - - // Cross-protocol coverage: OpenAI Chat Completions native shape. The - // tool-call signal is the top-level `tool_calls` array on the message, with - // no `stop_reason` / `stopReason` field (OpenAI uses `finish_reason` at the - // choice level which doesn't reach the message object). `hasPendingToolUse` - // must still flag this via the `tool_calls` array check. - it('keeps the run open for OpenAI ChatCompletions message with tool_calls array', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-openai-cc', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-openai-cc' }], - messages: [ - { id: 'user-occ', role: 'user', content: 'openai chat completions', timestamp: 5000 }, - ], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-openai-cc', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: 5000, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [ - { id: 'user-occ', role: 'user', content: 'openai chat completions', timestamp: 5000 }, - { - id: 'assistant-openai-cc-1', - role: 'assistant', - content: '', - tool_calls: [ - { - id: 'call_abc123', - type: 'function', - function: { name: 'web_search', arguments: '{"query":"foo"}' }, - }, - ], - timestamp: 5500, - }, - ], - }); - - await useChatStore.getState().loadHistory(true); - - const state = useChatStore.getState(); - expect(state.sending).toBe(true); - expect(state.activeRunId).toBe('run-openai-cc'); - }); - - // Cross-protocol coverage: OpenAI Chat Completions FINAL reply. No - // tool_calls, plain text content. Must close the run normally. - it('closes the run for OpenAI ChatCompletions plain-text final reply', async () => { - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-openai-cc-final', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-openai-cc-final' }], - messages: [ - { id: 'user-occf', role: 'user', content: 'openai final', timestamp: 6000 }, - ], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-openai-cc-final', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: true, - lastUserMessageAt: 6000, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [ - { id: 'user-occf', role: 'user', content: 'openai final', timestamp: 6000 }, - { - id: 'assistant-openai-cc-final-1', - role: 'assistant', - content: 'Here is the final answer.', - timestamp: 6500, - }, - ], - }); - - await useChatStore.getState().loadHistory(true); - - const state = useChatStore.getState(); - expect(state.sending).toBe(false); - expect(state.activeRunId).toBeNull(); - }); - - // Regression: the 90s safety timeout used to fire "No response received" - // while the model was still working via tool chains. Gateway streaming can be - // absent (WS drop, long tool execution) but chat.history still surfaces - // intermediate assistant turns — those must count as progress. - it('clears a stale no-response error when history poll shows tool progress', async () => { - const sendAtMs = 1_700_000_000_000; - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-stuck', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-stuck' }], - messages: [{ id: 'user-stuck', role: 'user', content: 'weather check' }], - sessionLabels: {}, - sessionLastActivity: {}, - sending: true, - activeRunId: 'run-stuck-test', - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: sendAtMs, - pendingToolImages: [], - error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.', - loading: false, - thinkingLevel: null, - }); - - gatewayRpcMock.mockResolvedValueOnce({ - messages: [ - { id: 'user-stuck', role: 'user', content: 'weather check', timestamp: sendAtMs }, - { - id: 'assistant-tool-stuck', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Searching...' }, - { type: 'toolCall', id: 'tool-stuck', name: 'web_search', input: { q: 'weather' } }, - ], - stopReason: 'toolUse', - timestamp: sendAtMs + 500, - }, - ], - }); - - await vi.advanceTimersByTimeAsync(1_000); - await useChatStore.getState().loadHistory(true); - - expect(useChatStore.getState().error).toBeNull(); - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().messages.some((msg) => msg.role === 'assistant')).toBe(true); - }); - - it('does not emit a false no-response error when history poll shows tool progress', async () => { - let resolveSend: ((value: { runId: string }) => void) | undefined; - gatewayRpcMock.mockImplementation((method: string) => { - if (method === 'chat.send') { - return new Promise((resolve) => { - resolveSend = resolve; - }); - } - if (method === 'chat.history') { - return { - messages: [ - { id: 'user-stuck', role: 'user', content: 'weather check', timestamp: 1000 }, - { - id: 'assistant-tool-stuck', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Searching...' }, - { type: 'toolCall', id: 'tool-stuck', name: 'web_search', input: { q: 'weather' } }, - ], - stopReason: 'toolUse', - timestamp: 1500, - }, - ], - }; - } - return { messages: [] }; - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-stuck', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-stuck' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - const sendPromise = useChatStore.getState().sendMessage('weather check'); - - await vi.advanceTimersByTimeAsync(7_000); - await useChatStore.getState().loadHistory(true); - await vi.advanceTimersByTimeAsync(100_000); - - expect(useChatStore.getState().error).toBeNull(); - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().messages.some((msg) => msg.role === 'assistant')).toBe(true); - - resolveSend?.({ runId: 'run-stuck-test' }); - await sendPromise; - }); - - it('surfaces an idle-timeout hint when a role-only stream placeholder stalls the run', async () => { - let resolveSend: ((value: { runId: string }) => void) | undefined; - gatewayRpcMock.mockImplementation((method: string) => { - if (method === 'chat.send') { - return new Promise((resolve) => { - resolveSend = resolve; - }); - } - if (method === 'chat.history') { - return { - messages: [ - { id: 'user-old', role: 'user', content: 'hello', timestamp: 1000 }, - { id: 'assistant-old', role: 'assistant', content: 'hi there', timestamp: 1500 }, - ], - }; - } - return { messages: [] }; - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-idle', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-idle' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - runError: null, - loading: false, - thinkingLevel: null, - }); - - const sendPromise = useChatStore.getState().sendMessage('long question'); - useChatStore.setState({ - streamingMessage: { role: 'assistant' }, - }); - - await vi.advanceTimersByTimeAsync(121_000); - - expect(useChatStore.getState().runError).toContain('120 seconds'); - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().streamingMessage).toBeNull(); - - await vi.advanceTimersByTimeAsync(20_000); - - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().error).toContain('No response received'); - - resolveSend?.({ runId: 'run-idle-test' }); - await sendPromise; - }); - - // Regression for the "first chat after gateway start" bug: the gateway - // accepted chat.send but no streamed chat/runtime events ever reached the - // renderer. Without the fallback transcript poll the safety timers fired - // "The model did not respond within 120 seconds" and then "No response - // received from the model" even though the transcript already contained - // the assistant reply. - it('recovers via the fallback transcript poll when no streamed events arrive', async () => { - let chatHistoryCalls = 0; - let transcript: Array> = []; - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'config.get') return {}; - if (method === 'chat.send') { - // Seed the transcript as the gateway would, but never emit events. - const nowSec = Date.now() / 1000; - transcript = [ - { id: 'user-first', role: 'user', content: '明天呢', timestamp: nowSec }, - { - id: 'assistant-first', - role: 'assistant', - content: [{ type: 'text', text: '明天晴。' }], - stopReason: 'endTurn', - timestamp: nowSec + 1, - }, - ]; - return { runId: 'run-first-chat' }; - } - if (method === 'chat.history') { - chatHistoryCalls += 1; - return { messages: transcript }; - } - return { messages: [] }; - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-first-chat', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-first-chat' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - runError: null, - loading: false, - thinkingLevel: null, - }); - - await useChatStore.getState().sendMessage('明天呢'); - - // While streamed events are still considered fresh the poll stays silent. - await vi.advanceTimersByTimeAsync(8_000); - expect(chatHistoryCalls).toBe(0); - - // After enough event silence the fallback poll reads the transcript, - // detects the finished reply, and closes the run without errors. - await vi.advanceTimersByTimeAsync(10_000); - expect(chatHistoryCalls).toBeGreaterThan(0); - await vi.waitFor(() => { - expect(useChatStore.getState().sending).toBe(false); - }); - expect(useChatStore.getState().messages.map((message) => message.id)).toEqual([ - 'user-first', - 'assistant-first', - ]); - - // The 120s idle hint and the 130s hard failure must never fire. - await vi.advanceTimersByTimeAsync(140_000); - expect(useChatStore.getState().error).toBeNull(); - expect(useChatStore.getState().runError).toBeNull(); - }); - - it('keeps the fallback poll silent while streamed events are fresh', async () => { - let chatHistoryCalls = 0; - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'config.get') return {}; - if (method === 'chat.send') { - return { runId: 'run-streamed' }; - } - if (method === 'chat.history') { - chatHistoryCalls += 1; - return { messages: [] }; - } - return { messages: [] }; - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-streamed', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-streamed' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - runError: null, - loading: false, - thinkingLevel: null, - }); - - await useChatStore.getState().sendMessage('streamed run'); - - // Streamed deltas keep refreshing the event timestamp; the fallback poll - // must not issue any chat.history RPCs while the stream is healthy. - for (let i = 0; i < 6; i += 1) { - useChatStore.getState().handleChatEvent({ - state: 'delta', - runId: 'run-streamed', - sessionKey: 'agent:main:session-streamed', - message: { role: 'assistant', content: [{ type: 'text', text: `chunk ${i}` }] }, - }); - await vi.advanceTimersByTimeAsync(5_000); - } - - expect(chatHistoryCalls).toBe(0); - expect(useChatStore.getState().sending).toBe(true); - }); - - it('does not treat prior-turn assistant history as progress for a new send', async () => { - let resolveSend: ((value: { runId: string }) => void) | undefined; - gatewayRpcMock.mockImplementation((method: string) => { - if (method === 'chat.send') { - return new Promise((resolve) => { - resolveSend = resolve; - }); - } - if (method === 'chat.history') { - return { - messages: [ - { id: 'user-old', role: 'user', content: 'hello', timestamp: 1000 }, - { id: 'assistant-old', role: 'assistant', content: 'hi there', timestamp: 1500 }, - ], - }; - } - return { messages: [] }; - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:session-idle', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:session-idle' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - runError: null, - loading: false, - thinkingLevel: null, - }); - - const sendPromise = useChatStore.getState().sendMessage('new question'); - await vi.advanceTimersByTimeAsync(121_000); - - expect(useChatStore.getState().runError).toContain('120 seconds'); - expect(useChatStore.getState().error).toBeNull(); - expect(useChatStore.getState().sending).toBe(true); - - resolveSend?.({ runId: 'run-idle-test-2' }); - await sendPromise; - }); - - it('clears persisted session attention after a production hard delete succeeds', async () => { - const deletedKey = 'agent:main:delete-me'; - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }, { key: deletedKey }], - }); - useSessionAttentionStore.setState({ - bySessionKey: { [deletedKey]: { observedBusy: false, unread: true } }, - visibleSessionKey: null, - }); - - await useChatStore.getState().deleteSession(deletedKey); - - expect(useSessionAttentionStore.getState().bySessionKey[deletedKey]).toBeUndefined(); - expect(JSON.parse(window.localStorage.getItem('clawx.session-attention') ?? '{}')).not.toEqual( - expect.objectContaining({ - state: expect.objectContaining({ - bySessionKey: expect.objectContaining({ [deletedKey]: expect.anything() }), - }), - }), - ); - }); -}); diff --git a/tests/unit/chat-store-session-label-fetch.test.ts b/tests/unit/chat-store-session-label-fetch.test.ts index 6f99b917..02af78d3 100644 --- a/tests/unit/chat-store-session-label-fetch.test.ts +++ b/tests/unit/chat-store-session-label-fetch.test.ts @@ -6,12 +6,9 @@ const runtimeStatus = { connectedAt: 0, }; -const { gatewayRpcMock, hostApiFetchMock, agentsState } = vi.hoisted(() => ({ +const { gatewayRpcMock, hostApiFetchMock } = vi.hoisted(() => ({ gatewayRpcMock: vi.fn(), hostApiFetchMock: vi.fn(), - agentsState: { - agents: [] as Array>, - }, })); function deferred() { @@ -33,30 +30,17 @@ vi.mock('@/stores/gateway', () => ({ }, })); -vi.mock('@/stores/agents', () => ({ - useAgentsStore: { - getState: () => agentsState, - }, -})); - vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), hostApi: { - media: { - thumbnails: vi.fn(async () => ({})), - }, sessions: { summaries: (input: unknown) => hostApiFetchMock('/api/sessions/summaries', { method: 'POST', body: JSON.stringify(input), }), - history: vi.fn(async () => ({ messages: [] })), delete: vi.fn(async () => ({ success: true })), rename: vi.fn(async () => ({ success: true })), }, - chat: { - sendWithMedia: vi.fn(async () => ({ success: true, result: { runId: 'run-media' } })), - }, }, })); @@ -69,7 +53,6 @@ describe('chat store session label summary hydration', () => { runtimeStatus.port = 18789; runtimeStatus.connectedAt = Date.now(); window.localStorage.clear(); - agentsState.agents = []; gatewayRpcMock.mockReset(); hostApiFetchMock.mockReset(); hostApiFetchMock.mockImplementation(async (path: string) => { @@ -139,91 +122,6 @@ describe('chat store session label summary hydration', () => { )).toBeNull(); }); - it('hydrates sidebar titles immediately after sessions load because summaries do not use gateway chat.history', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - sessions: [ - { key: 'agent:main:session-a', displayName: 'Session A', updatedAt: 1000 }, - { key: 'agent:main:main', displayName: 'Main', updatedAt: 1001 }, - ], - }; - } - - if (method === 'chat.history') { - return { - messages: [{ role: 'user', content: 'visible chat', timestamp: Date.now() }], - }; - } - - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/chat/history') { - throw new Error('No route for mocked chat host API'); - } - if (path === '/api/chat/sessions') { - return { - success: true, - result: { - sessions: [ - { key: 'agent:main:session-a', displayName: 'Session A', updatedAt: 1000 }, - { key: 'agent:main:main', displayName: 'Main', updatedAt: 1001 }, - ], - }, - }; - } - return { - success: true, - summaries: [ - { - sessionKey: 'agent:main:session-a', - firstUserText: 'should hydrate immediately', - lastTimestamp: 1_700_000_000_000, - }, - ], - }; - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - runError: null, - }); - - await useChatStore.getState().loadSessions(); - await Promise.resolve(); - await Promise.resolve(); - - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/sessions/summaries', { - method: 'POST', - body: JSON.stringify({ sessionKeys: ['agent:main:session-a', 'agent:main:main'] }), - }); - expect(useChatStore.getState().sessionLabels['agent:main:session-a']).toBe('should hydrate immediately'); - - const backgroundHistoryCalls = gatewayRpcMock.mock.calls.filter( - ([method, params]) => method === 'chat.history' && (params as Record | undefined)?.limit === 1000, - ); - expect(backgroundHistoryCalls).toHaveLength(0); - }); - it('replaces OpenClaw UUID-date fallback labels with the first user prompt', async () => { const sessionKey = 'agent:main:session-fallback'; const sessionId = '72e4b28b-8477-4e29-b57e-e14448fd42d0'; @@ -704,7 +602,7 @@ describe('chat store session label summary hydration', () => { expect(useChatStore.getState().sessionLabels['agent:main:session-a']?.startsWith('[Working directory: /user-chosen]')).toBe(true); }); - it('keeps a formatted explicit backend label during history and summary refreshes', async () => { + it('keeps a formatted explicit backend label during repeated catalog and summary refreshes', async () => { const sessionKey = 'agent:main:session-a'; const explicitLabel = '[Working directory: /user-chosen]\nManual title '; const expectedDisplayLabel = '[Working directory: /user-chosen]\nManual title'; @@ -719,16 +617,6 @@ describe('chat store session label summary hydration', () => { ], }; } - if (method === 'chat.history') { - return { - messages: [{ - role: 'user', - content: '[Working directory: ~/.openclaw/workspace]\n\nHistory automatic title', - timestamp: 1000, - }], - }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); }); hostApiFetchMock.mockImplementation(async (path: string) => { @@ -783,23 +671,14 @@ describe('chat store session label summary hydration', () => { expect(summaryRequests).toBe(1); }); - await useChatStore.getState().loadHistory(false); + await useChatStore.getState().loadSessions({ force: true }); - await vi.waitFor(() => { - const summaryCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/sessions/summaries'); - expect(summaryCalls).toHaveLength(2); - expect(summaryCalls[1]).toEqual([ - '/api/sessions/summaries', - { - method: 'POST', - body: JSON.stringify({ sessionKeys: [sessionKey, 'agent:main:main'] }), - }, - ]); - expect(useChatStore.getState().sessionLastActivity[sessionKey]).toBe(2_000); - }); + expect(hostApiFetchMock.mock.calls.filter( + ([path]) => path === '/api/sessions/summaries', + )).toHaveLength(1); const state = useChatStore.getState(); - expect(state.sessionLabels[sessionKey]).toBeUndefined(); + expect(state.sessionLabels[sessionKey]).toBe(expectedDisplayLabel); expect(state.sessions.find((session) => session.key === sessionKey)?.label).toBe(explicitLabel); }); @@ -929,12 +808,6 @@ describe('chat store session label summary hydration', () => { }; } - if (method === 'chat.history') { - return { - messages: [{ role: 'user', content: 'visible chat', timestamp: Date.now() }], - }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); }); @@ -997,7 +870,7 @@ describe('chat store session label summary hydration', () => { expect(useChatStore.getState().sessionLabels['agent:main:session-b']).toBe('Beta title'); }); - it('hydrates session labels through the host API instead of gateway chat.history fan-out', async () => { + it('hydrates missing session labels and activity from host summaries', async () => { gatewayRpcMock.mockImplementation(async (method: string) => { if (method === 'sessions.list') { return { @@ -1010,12 +883,6 @@ describe('chat store session label summary hydration', () => { }; } - if (method === 'chat.history') { - return { - messages: [{ role: 'user', content: 'visible chat', timestamp: Date.now() }], - }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); }); @@ -1060,10 +927,6 @@ describe('chat store session label summary hydration', () => { runError: null, }); - await useChatStore.getState().loadHistory(false); - hostApiFetchMock.mockClear(); - gatewayRpcMock.mockClear(); - vi.advanceTimersByTime(1_500); await useChatStore.getState().loadSessions(); await Promise.resolve(); @@ -1075,10 +938,6 @@ describe('chat store session label summary hydration', () => { }); expect(useChatStore.getState().sessionLabels['agent:main:session-c']).toBe('needs label'); expect(useChatStore.getState().sessionLastActivity['agent:main:session-c']).toBe(1_700_000_000_123); - const backgroundHistoryCalls = gatewayRpcMock.mock.calls.filter( - ([method, params]) => method === 'chat.history' && (params as Record | undefined)?.limit === 1000, - ); - expect(backgroundHistoryCalls).toHaveLength(0); }); it('does not re-request label hydration for unchanged sessions across repeated loadSessions calls', async () => { @@ -1092,12 +951,6 @@ describe('chat store session label summary hydration', () => { }; } - if (method === 'chat.history') { - return { - messages: [{ role: 'user', content: 'visible chat', timestamp: Date.now() }], - }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); }); @@ -1173,12 +1026,6 @@ describe('chat store session label summary hydration', () => { }; } - if (method === 'chat.history') { - return { - messages: [{ role: 'user', content: 'visible chat', timestamp: Date.now() }], - }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); }); @@ -1276,12 +1123,6 @@ describe('chat store session label summary hydration', () => { }; } - if (method === 'chat.history') { - return { - messages: [{ role: 'user', content: 'visible chat', timestamp: Date.now() }], - }; - } - throw new Error(`Unexpected gateway RPC: ${method} ${JSON.stringify(params)}`); }); @@ -1326,7 +1167,7 @@ describe('chat store session label summary hydration', () => { runError: null, }); - await useChatStore.getState().loadHistory(false); + await useChatStore.getState().loadSessions({ force: true }); await Promise.resolve(); await Promise.resolve(); @@ -1374,10 +1215,19 @@ describe('chat store session label summary hydration', () => { sessionLastActivity: { [unrelatedKey]: 1_700_000_009_000 }, }); - await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - expect(summaryCalls).toBe(1); - + const firstLoading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + await vi.waitFor(() => expect(summaryCalls).toBe(1)); const reloading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 2 }); + oldSummary.resolve({ + success: true, + summaries: [{ + sessionKey, + firstUserText: 'stale title', + lastTimestamp: 1_700_000_001_000, + workspacePath: '/stale', + }], + }); + await vi.waitFor(() => expect(listCalls).toBe(2)); useChatStore.getState().handleSessionsChanged({ sessionKey, reason: 'delete', ts: 21 }); useChatStore.getState().handleSessionsChanged({ key: sessionKey, @@ -1391,22 +1241,8 @@ describe('chat store session label summary hydration', () => { { key: unrelatedKey, label: 'Unrelated', workspacePath: '/unrelated' }, ], }); - await reloading; - expect(summaryCalls).toBe(2); - - oldSummary.resolve({ - success: true, - summaries: [{ - sessionKey, - firstUserText: 'stale title', - lastTimestamp: 1_700_000_001_000, - workspacePath: '/stale', - }], - }); - await Promise.resolve(); - await Promise.resolve(); + await vi.waitFor(() => expect(summaryCalls).toBe(2)); expect(useChatStore.getState().sessionLabels[sessionKey]).toBeUndefined(); - expect(useChatStore.getState().sessionLastActivity[sessionKey]).toBe(1_700_000_000_000); expect(useChatStore.getState().sessions.find((session) => session.key === sessionKey)?.workspacePath).toBeUndefined(); newSummary.resolve({ @@ -1418,6 +1254,7 @@ describe('chat store session label summary hydration', () => { workspacePath: '/fresh', }], }); + await Promise.all([firstLoading, reloading]); await vi.waitFor(() => { expect(useChatStore.getState().sessionLabels[sessionKey]).toBe('fresh title'); }); @@ -1427,79 +1264,4 @@ describe('chat store session label summary hydration', () => { expect(useChatStore.getState().sessionLastActivity[unrelatedKey]).toBe(1_700_000_009_000); }); - it('drops a stale visible-session refresh after buffered delete-recreate', async () => { - const sessionKey = 'agent:main:recreated-refresh'; - const list = deferred>(); - const oldRefresh = deferred>(); - const newSummary = deferred>(); - let summaryCalls = 0; - - gatewayRpcMock.mockImplementation((method: string) => { - if (method === 'chat.history') return Promise.resolve({ messages: [] }); - if (method === 'sessions.list') return list.promise; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - hostApiFetchMock.mockImplementation((path: string) => { - if (path !== '/api/sessions/summaries') return Promise.resolve({ success: true }); - summaryCalls += 1; - return summaryCalls === 1 ? oldRefresh.promise : newSummary.promise; - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: sessionKey, - currentAgentId: 'main', - sessions: [{ key: sessionKey, updatedAt: 1_700_000_000_000 }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - loading: false, - }); - - await useChatStore.getState().loadHistory(false); - expect(summaryCalls).toBe(1); - - const reloading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ sessionKey, reason: 'delete', ts: 31 }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, - ts: 32, - session: { key: sessionKey, updatedAt: 1_700_000_000_000 }, - }); - list.resolve({ - ts: 30, - sessions: [{ key: sessionKey, updatedAt: 1_700_000_000_000 }], - }); - await reloading; - expect(summaryCalls).toBe(2); - - oldRefresh.resolve({ - success: true, - summaries: [{ - sessionKey, - firstUserText: 'stale refresh', - lastTimestamp: 1_700_000_003_000, - workspacePath: '/stale-refresh', - }], - }); - await Promise.resolve(); - await Promise.resolve(); - expect(useChatStore.getState().sessionLabels[sessionKey]).toBeUndefined(); - expect(useChatStore.getState().sessions[0]?.workspacePath).toBeUndefined(); - - newSummary.resolve({ - success: true, - summaries: [{ - sessionKey, - firstUserText: 'fresh refresh', - lastTimestamp: 1_700_000_004_000, - workspacePath: '/fresh-refresh', - }], - }); - await vi.waitFor(() => { - expect(useChatStore.getState().sessionLabels[sessionKey]).toBe('fresh refresh'); - }); - expect(useChatStore.getState().sessionLastActivity[sessionKey]).toBe(1_700_000_004_000); - expect(useChatStore.getState().sessions[0]?.workspacePath).toBe('/fresh-refresh'); - }); }); diff --git a/tests/unit/chat-target-routing.test.ts b/tests/unit/chat-target-routing.test.ts deleted file mode 100644 index 7202b342..00000000 --- a/tests/unit/chat-target-routing.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { chatHistoryRpcParams } from './gateway-rpc-test-utils'; - -const { gatewayRpcMock, hostApiFetchMock, agentsState } = vi.hoisted(() => ({ - gatewayRpcMock: vi.fn(), - hostApiFetchMock: vi.fn(), - agentsState: { - agents: [] as Array>, - }, -})); - -vi.mock('@/stores/gateway', () => ({ - useGatewayStore: { - getState: () => ({ - rpc: gatewayRpcMock, - }), - }, -})); - -vi.mock('@/stores/agents', () => ({ - useAgentsStore: { - getState: () => agentsState, - }, -})); - -vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), - hostApi: { - gateway: { - rpc: (...args: unknown[]) => gatewayRpcMock(...args), - }, - media: { - thumbnails: vi.fn(async () => ({})), - }, - sessions: { - history: vi.fn(async () => ({ messages: [] })), - summaries: vi.fn(async () => ({ success: true, summaries: [] })), - delete: vi.fn(async () => ({ success: true })), - rename: vi.fn(async () => ({ success: true })), - }, - chat: { - sendWithMedia: async (input: unknown) => hostApiFetchMock('/api/chat/send-with-media', { - method: 'POST', - body: JSON.stringify(input), - }), - }, - }, -})); - -describe('chat target routing', () => { - beforeEach(() => { - vi.resetModules(); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-03-11T12:00:00Z')); - window.localStorage.clear(); - - agentsState.agents = [ - { - id: 'main', - name: 'Main', - isDefault: true, - modelDisplay: 'MiniMax', - inheritedModel: true, - workspace: '~/.openclaw/workspace', - agentDir: '~/.openclaw/agents/main/agent', - mainSessionKey: 'agent:main:main', - channelTypes: [], - }, - { - id: 'research', - name: 'Research', - isDefault: false, - modelDisplay: 'Claude', - inheritedModel: false, - workspace: '~/.openclaw/workspace-research', - agentDir: '~/.openclaw/agents/research/agent', - mainSessionKey: 'agent:research:desk', - channelTypes: [], - }, - ]; - - gatewayRpcMock.mockReset(); - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'config.get') { - return { messages: [] }; - } - if (method === 'chat.history') { - return { messages: [] }; - } - if (method === 'chat.send') { - return { runId: 'run-text' }; - } - if (method === 'chat.abort') { - return { ok: true }; - } - if (method === 'sessions.list') { - return { sessions: [] }; - } - throw new Error(`Unexpected gateway RPC: ${method}`); - }); - - hostApiFetchMock.mockReset(); - hostApiFetchMock.mockImplementation(async (url: string) => { - if (url === '/api/chat/history') { - return { success: true, result: { messages: [] } }; - } - if (url === '/api/chat/send') { - return { success: true, result: { runId: 'run-text' } }; - } - if (url === '/api/chat/send-with-media') { - return { success: true, result: { runId: 'run-media' } }; - } - return { success: true, result: {} }; - }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('switches to the selected agent main session before sending text', async () => { - const { useChatStore } = await import('@/stores/chat'); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [{ role: 'assistant', content: 'Existing main history' }], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - await useChatStore.getState().sendMessage('Hello direct agent', undefined, 'research'); - - const state = useChatStore.getState(); - expect(state.currentSessionKey).toBe('agent:research:desk'); - expect(state.currentAgentId).toBe('research'); - expect(state.sessions.some((session) => session.key === 'agent:research:desk')).toBe(true); - expect(state.messages.at(-1)?.content).toBe('Hello direct agent'); - - const historyCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.history'); - expect(historyCall?.[1]).toEqual( - chatHistoryRpcParams('agent:research:desk', 200), - ); - - const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send'); - const sendPayload = (sendCall?.[1] ?? {}) as Record; - expect(sendPayload).toMatchObject({ - sessionKey: 'agent:research:desk', - message: 'Hello direct agent', - deliver: false, - }); - expect(typeof (sendPayload as { idempotencyKey?: unknown }).idempotencyKey).toBe('string'); - }); - - it('uses the selected agent main session for attachment sends', async () => { - const { useChatStore } = await import('@/stores/chat'); - - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - currentAgentId: 'main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sessionLabels: {}, - sessionLastActivity: {}, - sending: false, - activeRunId: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], - error: null, - loading: false, - thinkingLevel: null, - }); - - await useChatStore.getState().sendMessage( - '', - [ - { - fileName: 'design.png', - mimeType: 'image/png', - fileSize: 128, - stagedPath: '/tmp/design.png', - preview: 'data:image/png;base64,abc', - }, - ], - 'research', - ); - - expect(useChatStore.getState().currentSessionKey).toBe('agent:research:desk'); - - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/chat/send-with-media', - expect.objectContaining({ - method: 'POST', - body: expect.any(String), - }), - ); - - const mediaSendCall = hostApiFetchMock.mock.calls.find(([url]) => url === '/api/chat/send-with-media'); - const payload = JSON.parse( - (mediaSendCall?.[1] as { body: string }).body, - ) as { - sessionKey: string; - message: string; - media: Array<{ filePath: string }>; - }; - - expect(payload.sessionKey).toBe('agent:research:desk'); - expect(payload.message).toBe('Process the attached file(s).'); - expect(payload.media[0]?.filePath).toBe('/tmp/design.png'); - }); -}); diff --git a/tests/unit/chat-tool-card-suppression.test.tsx b/tests/unit/chat-tool-card-suppression.test.tsx index ef5b710c..88c6e27e 100644 --- a/tests/unit/chat-tool-card-suppression.test.tsx +++ b/tests/unit/chat-tool-card-suppression.test.tsx @@ -44,8 +44,6 @@ const { acpState, agentsState, artifactPanelState, chatState, settingsState } = sessions: [{ key: 'agent:main:main', workspacePath: '/workspace' }], currentSessionKey: 'agent:main:main', currentAgentId: 'main', - loading: false, - refresh: vi.fn(), loadSessions: vi.fn().mockResolvedValue(undefined), selectAcpSession: vi.fn(), acknowledgeAcpSessionCreated: vi.fn(), @@ -84,20 +82,12 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, params?: Record | string) => { if (typeof params === 'string') return params; - if (key === 'executionGraph.collapsedSummary') { - return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim(); - } - if (key === 'executionGraph.agentRun') return 'Main execution'; - if (key === 'executionGraph.title') return 'Execution Graph'; - if (key === 'executionGraph.collapseAction') return 'Collapse'; - if (key === 'executionGraph.thinkingLabel') return 'Thinking'; if (key === 'acp.tool') return 'Tool'; if (key === 'acp.completed') return 'Completed'; if (key === 'acp.toolGroupSummary') return `${String(params?.count ?? '')} tool calls`; if (key === 'acp.expandToolGroup') return 'Expand tool calls'; if (key === 'acp.collapseToolGroup') return 'Collapse tool calls'; if (key === 'welcome.subtitle') return 'What can I do for you?'; - if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key; return key; }, }), @@ -218,7 +208,7 @@ describe('Chat tool card suppression', () => { settingsState.chatWorkspacePath = '/workspace'; }); - it('renders ACP tool calls inline without the legacy execution graph', () => { + it('renders ACP tool calls inline', () => { render(); expect(screen.getByTestId('acp-chat-timeline')).toBeInTheDocument(); @@ -226,6 +216,5 @@ describe('Chat tool card suppression', () => { expect(screen.getByTestId('acp-tool-calls-group')).toHaveTextContent('3 tool calls'); expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument(); expect(screen.getByText('All done.')).toBeInTheDocument(); - expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument(); }); }); diff --git a/tests/unit/chat-toolbar.test.tsx b/tests/unit/chat-toolbar.test.tsx new file mode 100644 index 00000000..1e523bc7 --- /dev/null +++ b/tests/unit/chat-toolbar.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react'; +import { TooltipProvider } from '@radix-ui/react-tooltip'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChatToolbar } from '@/pages/Chat/ChatToolbar'; + +const { agentsState, artifactPanelState, chatState } = vi.hoisted(() => ({ + agentsState: { agents: [{ id: 'main', name: 'Main' }] }, + artifactPanelState: { + open: false, + tab: 'changes', + openBrowser: vi.fn(), + close: vi.fn(), + }, + chatState: { currentAgentId: 'main' }, +})); + +vi.mock('@/stores/agents', () => ({ + useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState), +})); + +vi.mock('@/stores/artifact-panel', () => ({ + useArtifactPanel: (selector: (state: typeof artifactPanelState) => unknown) => selector(artifactPanelState), +})); + +vi.mock('@/stores/chat', () => ({ + useChatStore: (selector: (state: typeof chatState) => unknown) => selector(chatState), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => ({ + 'toolbar.currentAgent': 'Talking to Main', + 'toolbar.refresh': 'Refresh', + 'toolbar.workspace': 'Workspace', + 'questionDirectory.title': 'Question directory', + })[key] ?? key, + }), +})); + +function renderToolbar(props: React.ComponentProps = {}) { + return render( + + + , + ); +} + +describe('ChatToolbar', () => { + beforeEach(() => { + artifactPanelState.open = false; + artifactPanelState.tab = 'changes'; + }); + + it('does not expose the removed refresh action', () => { + renderToolbar(); + + expect(screen.queryByRole('button', { name: 'Refresh' })).not.toBeInTheDocument(); + expect(screen.getByText('Talking to Main')).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/chat-user-message-display.test.ts b/tests/unit/chat-user-message-display.test.ts deleted file mode 100644 index c50ea9d4..00000000 --- a/tests/unit/chat-user-message-display.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractText } from '@/pages/Chat/message-utils'; -import { matchesOptimisticUserMessage } from '@/stores/chat/helpers'; - -const gatewayImageEcho = [ - 'Sender (untrusted metadata):', - '```json', - '{', - ' "label": "ClawX (gateway-client)",', - ' "id": "gateway-client",', - ' "name": "ClawX",', - ' "username": "ClawX"', - '}', - '```', - '', - '[media attached: media://inbound/image---abc.png (image/png)]', - '[Image]', - 'User text:', - 'Process the attached file(s).', - '[media attached: /Users/test/.openclaw/media/outbound/out.png (image/png) | /Users/test/.openclaw/media/outbound/out.png]', - 'Description:', - 'An astronaut in a white space suit floats in space, reaching a gloved hand toward the viewer.', -].join('\n'); - -describe('user message display cleanup', () => { - it('hides the inbound-image vision envelope for attachment-only uploads', () => { - expect(extractText({ role: 'user', content: gatewayImageEcho })).toBe(''); - }); - - it('keeps the user caption while stripping auto-generated description', () => { - const content = gatewayImageEcho - .replace('Process the attached file(s).', '改成西装加领带'); - - expect(extractText({ role: 'user', content })).toBe('改成西装加领带'); - }); - - it('matches optimistic attachment-only bubbles against the gateway vision echo', () => { - const optimistic = { - role: 'user' as const, - content: '(file attached)', - timestamp: 1_700_000_000, - _attachedFiles: [{ - fileName: 'out.png', - mimeType: 'image/png', - fileSize: 123, - preview: null, - filePath: '/Users/test/.openclaw/media/outbound/out.png', - }], - }; - const candidate = { - role: 'user' as const, - content: gatewayImageEcho, - timestamp: 1_700_000_000, - }; - - expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true); - }); -}); diff --git a/tests/unit/config-mutex.test.ts b/tests/unit/config-mutex.test.ts new file mode 100644 index 00000000..9b30b1ed --- /dev/null +++ b/tests/unit/config-mutex.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest'; +import { withConfigLock } from '@electron/utils/config-mutex'; + +describe('config mutex', () => { + it('serializes concurrent config operations', async () => { + const order: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withConfigLock(async () => { + order.push('first:start'); + await firstGate; + order.push('first:end'); + }); + const second = withConfigLock(async () => { + order.push('second'); + }); + + await vi.waitFor(() => expect(order).toEqual(['first:start'])); + releaseFirst?.(); + await Promise.all([first, second]); + + expect(order).toEqual(['first:start', 'first:end', 'second']); + }); + + it('allows awaited nested config operations without deadlocking', async () => { + const values: string[] = []; + + await withConfigLock(async () => { + values.push('outer'); + await withConfigLock(async () => { + values.push('inner'); + }); + }); + + expect(values).toEqual(['outer', 'inner']); + }); +}); diff --git a/tests/unit/execution-graph-card-badge.test.tsx b/tests/unit/execution-graph-card-badge.test.tsx deleted file mode 100644 index cd82a13b..00000000 --- a/tests/unit/execution-graph-card-badge.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; - -import { ExecutionGraphCard } from '@/pages/Chat/ExecutionGraphCard'; -import type { TaskStep } from '@/pages/Chat/task-visualization'; - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, params?: Record) => { - if (key === 'executionGraph.title') return 'Execution Graph'; - if (key === 'executionGraph.collapseAction') return 'Collapse'; - if (key === 'executionGraph.collapsedSummary') { - return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim(); - } - if (key === 'executionGraph.agentRun') return `${String(params?.agent ?? '')} execution`; - if (key === 'executionGraph.thinkingLabel') return 'Thinking'; - // Use the actual zh string here so a regression that drops - // whitespace-nowrap would let "分支" line-break in narrow flex rows. - if (key === 'executionGraph.branchLabel') return '分支'; - if (key.startsWith('taskPanel.stepStatus.')) { - return key.split('.').at(-1) ?? key; - } - return key; - }, - }), -})); - -// Step rendered as a depth>1 child of a subagent run, exercising both the -// depth-driven branch badge and the status pill that share the same span -// styles in `ExecutionGraphCard`. -const branchStep: TaskStep = { - id: 'sub-exec-1', - label: 'exec', - kind: 'tool', - status: 'running', - depth: 2, - parentId: 'sub-root', - detail: '{ "command": "openclaw gateway start", "yieldMs": 10000, "timeout": 60 }', -}; - -describe('ExecutionGraphCard branch badge', () => { - it('renders the localized branch label without intra-badge wrapping', () => { - render( - , - ); - - const branchBadge = screen.getByText('分支'); - expect(branchBadge.tagName.toLowerCase()).toBe('span'); - // CJK strings can break between any two glyphs under flex shrink, which - // visually stacks "分" / "支" on two lines. Both classes together keep the - // pill on one row regardless of locale or container width. - expect(branchBadge.className).toContain('whitespace-nowrap'); - expect(branchBadge.className).toContain('shrink-0'); - }); - - it('applies the same wrap-safe classes to the visible status pill', () => { - // Tool steps with status="error" render the status pill (running shows - // dots and completed hides the pill on tool rows). - const erroredToolStep: TaskStep = { - id: 'sub-exec-1', - label: 'exec', - kind: 'tool', - status: 'error', - depth: 1, - detail: '{ "command": "openclaw gateway start" }', - }; - - render( - , - ); - - const statusPill = screen.getByText('error'); - expect(statusPill.className).toContain('whitespace-nowrap'); - expect(statusPill.className).toContain('shrink-0'); - }); -}); - -describe('ExecutionGraphCard subagent (system) row', () => { - // Subagent branch root produced by Chat/index.tsx: - // { kind: 'system', label: ' subagent', detail: '' }. - // It must render with the same flat row style as a tool call, NOT the old - // bordered-card layout that put the session key on a second visible line. - const subagentStep: TaskStep = { - id: 'subagent:08efe821', - label: 'main subagent', - kind: 'system', - status: 'completed', - detail: 'agent:main:subagent:08efe821-2717-4395-b3d7-a8f50928155f', - depth: 1, - parentId: 'agent-run', - }; - - it('uses the flat tool-style container without the rounded-card chrome', () => { - const { container } = render( - , - ); - - const stepRow = container.querySelector('[data-testid="chat-execution-step"]'); - expect(stepRow).not.toBeNull(); - const detailContainer = stepRow!.querySelector(':scope > div:nth-of-type(2)'); - expect(detailContainer).not.toBeNull(); - const className = detailContainer!.className; - expect(className).toContain('px-0'); - expect(className).toContain('py-0'); - expect(className).not.toContain('rounded-xl'); - expect(className).not.toContain('border-black/10'); - }); - - it('renders the subagent session key inline as a single truncated preview', () => { - render( - , - ); - - const preview = screen.getByText(subagentStep.detail!); - expect(preview.tagName.toLowerCase()).toBe('p'); - expect(preview.className).toContain('truncate'); - // The label is the bold title sibling of the preview. - const titleNode = screen.getByText('main subagent'); - expect(titleNode.className).toContain('font-medium'); - expect(preview.parentElement).toBe(titleNode.parentElement); - }); - - it('omits the redundant "completed" status pill for finished subagent rows', () => { - render( - , - ); - - expect(screen.queryByText('completed')).toBeNull(); - }); - - it('expands to a code block when the row is clicked', () => { - const { container } = render( - , - ); - - expect(container.querySelector('pre')).toBeNull(); - fireEvent.click(screen.getByText('main subagent')); - const pre = container.querySelector('pre'); - expect(pre).not.toBeNull(); - expect(pre!.textContent).toBe(subagentStep.detail); - }); -}); diff --git a/tests/unit/file-preview-body.test.tsx b/tests/unit/file-preview-body.test.tsx index fb86c6e7..da2d40ad 100644 --- a/tests/unit/file-preview-body.test.tsx +++ b/tests/unit/file-preview-body.test.tsx @@ -503,7 +503,7 @@ describe('FilePreviewBody', () => { />, ); - expect(await screen.findByText('This file format is not supported for inline preview or diff')).toBeVisible(); + expect(await screen.findByText('This file format is not supported for inline preview')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Open directly' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Show in file manager' })).not.toBeInTheDocument(); expect(dialogMessageMock).not.toHaveBeenCalled(); diff --git a/tests/unit/gateway-bisection-0d794cd.test.ts b/tests/unit/gateway-bisection-0d794cd.test.ts deleted file mode 100644 index 15a3dfa8..00000000 --- a/tests/unit/gateway-bisection-0d794cd.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Bisection tests for 0d794cd ("fix per channel per session") vs de3046a. - * - * Part A — dmScope effect is simulated by Gateway event sessionKey alignment - * (de3046a production used dmScope=main → events on agent:main:main while UI - * showed feishu keys; 0d794cd sets per-channel-peer → keys match). - * - * Part B — sessions.subscribe adds handleGatewaySessionsChanged → loadSessions. - */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const hostApiMock = vi.hoisted(() => ({ - gateway: { - status: vi.fn(), - start: vi.fn(), - stop: vi.fn(), - restart: vi.fn(), - health: vi.fn(), - controlUi: vi.fn(), - rpc: vi.fn(), - }, - settings: { - getAll: vi.fn(), - get: vi.fn(), - set: vi.fn(), - setMany: vi.fn(), - reset: vi.fn(), - }, - logs: { - recent: vi.fn(), - dir: vi.fn(), - listFiles: vi.fn(), - readFile: vi.fn(), - }, -})); -const hostEventSubscriptionMock = vi.fn(); - -function flushAsyncImports(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -function captureHandlers() { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - return handlers; -} - -vi.mock('@/lib/host-api', () => ({ - hostApi: hostApiMock, -})); - -vi.mock('@/lib/host-events', () => ({ - hostEvents: { - onGatewayStatus: (handler: unknown) => hostEventSubscriptionMock('gateway:status', handler), - onGatewayError: (handler: unknown) => hostEventSubscriptionMock('gateway:error', handler), - onGatewayNotification: (handler: unknown) => hostEventSubscriptionMock('gateway:notification', handler), - onGatewayHealth: (handler: unknown) => hostEventSubscriptionMock('gateway:health', handler), - onGatewayPresence: (handler: unknown) => hostEventSubscriptionMock('gateway:presence', handler), - onGatewayChatMessage: (handler: unknown) => hostEventSubscriptionMock('gateway:chat-message', handler), - onGatewaySessionsChanged: (handler: unknown) => hostEventSubscriptionMock('gateway:sessions-changed', handler), - onChatRuntimeEvent: (handler: unknown) => hostEventSubscriptionMock('chat:runtime-event', handler), - onGatewayChannelStatus: (handler: unknown) => hostEventSubscriptionMock('gateway:channel-status', handler), - }, -})); - -const FEISHU_KEY = 'agent:main:feishu:direct:ou_test'; -const MAIN_KEY = 'agent:main:main'; -const OTHER_FEISHU_KEY = 'agent:main:feishu:direct:ou_other'; - -describe('bisection 0d794cd vs de3046a', () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - hostApiMock.gateway.status.mockResolvedValue({ state: 'running', port: 18789, gatewayReady: true }); - }); - - async function initGatewayHandlers() { - const handlers = captureHandlers(); - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - return handlers; - } - - function subscribedEvents(): string[] { - return hostEventSubscriptionMock.mock.calls.map(([eventName]) => String(eventName)); - } - - function hasSessionsChangedWiring(): boolean { - return subscribedEvents().includes('gateway:sessions-changed'); - } - - describe('Part A — dmScope key alignment (simulated via runtime event sessionKey)', () => { - it('de3046a baseline: run.started on main key does NOT reload history for feishu view', async () => { - const handlers = await initGatewayHandlers(); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: FEISHU_KEY, - sessions: [{ key: FEISHU_KEY }], - sending: true, - activeRunId: 'run-user', - lastUserMessageAt: Date.now(), - loadHistory, - }); - - handlers.get('chat:runtime-event')?.({ - type: 'run.started', - runId: 'run-inbound', - sessionKey: MAIN_KEY, - startedAt: Date.now(), - }); - await flushAsyncImports(); - - expect(loadHistory).not.toHaveBeenCalled(); - }); - - it('0d794cd with dmScope: aligned run.started DOES reload history (regression trigger)', async () => { - const handlers = await initGatewayHandlers(); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: FEISHU_KEY, - sessions: [{ key: FEISHU_KEY }], - sending: true, - activeRunId: 'run-user', - lastUserMessageAt: Date.now(), - loadHistory, - }); - - handlers.get('chat:runtime-event')?.({ - type: 'run.started', - runId: 'run-user', - sessionKey: FEISHU_KEY, - startedAt: Date.now(), - }); - await flushAsyncImports(); - - expect(loadHistory).toHaveBeenCalled(); - }); - - it('0d794cd with dmScope: aligned run.ended also reloads history', async () => { - const handlers = await initGatewayHandlers(); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: FEISHU_KEY, - sessions: [{ key: FEISHU_KEY }], - sending: true, - activeRunId: 'run-user', - lastUserMessageAt: Date.now(), - loadHistory, - }); - - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-user', - sessionKey: FEISHU_KEY, - status: 'completed', - endedAt: Date.now(), - }); - await flushAsyncImports(); - - expect(loadHistory).toHaveBeenCalled(); - }); - }); - - describe('Part B — sessions.subscribe (sessions.changed handler)', () => { - it('records whether gateway:sessions-changed is wired (de3046a=false, 0d794cd=true)', async () => { - await initGatewayHandlers(); - const wired = hasSessionsChangedWiring(); - - console.log(`[bisect] gateway:sessions-changed wired=${wired}`); - expect([true, false]).toContain(wired); - }); - - it('other-session sessions.changed triggers loadSessions only when wired', async () => { - const handlers = await initGatewayHandlers(); - const wired = hasSessionsChangedWiring(); - - const { useChatStore } = await import('@/stores/chat'); - const loadSessions = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: FEISHU_KEY, - sessions: [{ key: FEISHU_KEY }], - loadSessions, - }); - await flushAsyncImports(); - loadSessions.mockClear(); - - handlers.get('gateway:sessions-changed')?.({ - sessionKey: OTHER_FEISHU_KEY, - phase: 'start', - ts: Date.now(), - }); - await flushAsyncImports(); - - if (wired) { - expect(loadSessions).toHaveBeenCalled(); - } else { - expect(handlers.has('gateway:sessions-changed')).toBe(false); - expect(loadSessions).not.toHaveBeenCalled(); - } - }); - - it('current-session sessions.changed skips loadSessions when wired', async () => { - const handlers = await initGatewayHandlers(); - if (!hasSessionsChangedWiring()) { - expect(handlers.has('gateway:sessions-changed')).toBe(false); - return; - } - - const { useChatStore } = await import('@/stores/chat'); - const loadSessions = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: FEISHU_KEY, - sessions: [{ key: FEISHU_KEY }], - loadSessions, - }); - - handlers.get('gateway:sessions-changed')?.({ - sessionKey: FEISHU_KEY, - phase: 'start', - ts: Date.now(), - }); - await flushAsyncImports(); - - expect(loadSessions).not.toHaveBeenCalled(); - }); - - it('loadSessions reconcile can clear in-flight sending (path exists on both commits; 0d794cd triggers it via sessions.changed)', async () => { - await initGatewayHandlers(); - // updatedAt newer than the in-flight send clears run lifecycle. - const lastUserMessageAt = 1_779_693_769_991; - hostApiMock.gateway.rpc.mockResolvedValue({ - sessions: [{ - key: FEISHU_KEY, - updatedAt: 1_779_694_521_057, - status: 'done', - hasActiveRun: false, - lastMessagePreview: 'hello from feishu', - }], - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: FEISHU_KEY, - sessions: [{ key: FEISHU_KEY }], - sending: true, - activeRunId: 'run-active', - pendingFinal: true, - lastUserMessageAt, - }); - - await useChatStore.getState().loadSessions(); - - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - }); - }); -}); diff --git a/tests/unit/gateway-config-delivery.test.ts b/tests/unit/gateway-config-delivery.test.ts new file mode 100644 index 00000000..392a8df6 --- /dev/null +++ b/tests/unit/gateway-config-delivery.test.ts @@ -0,0 +1,468 @@ +// @vitest-environment node + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + mutateOpenClawConfig, + readOpenClawConfigSnapshot, + registerOpenClawConfigCoordinator, + resetOpenClawConfigCoordinatorForTests, +} from '@electron/gateway/config-delivery'; + +const { renameMock } = vi.hoisted(() => ({ + renameMock: vi.fn(), +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + renameMock.mockImplementation(actual.rename); + return { + ...actual, + rename: renameMock, + }; +}); + +interface TestGatewayManager { + getStatus: ReturnType; + rpc: ReturnType; + restart: ReturnType; +} + +function createGatewayManager(state: 'running' | 'stopped' | 'starting' = 'running'): TestGatewayManager { + return { + getStatus: vi.fn(() => ({ state })), + rpc: vi.fn(), + restart: vi.fn(), + }; +} + +describe('OpenClaw config delivery coordinator', () => { + let testDir: string; + let configPath: string; + let previousConfigPath: string | undefined; + + beforeEach(async () => { + renameMock.mockClear(); + resetOpenClawConfigCoordinatorForTests(); + testDir = await mkdtemp(join(tmpdir(), 'clawx-config-delivery-')); + configPath = join(testDir, 'configured-openclaw.json5'); + previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_CONFIG_PATH = configPath; + }); + + afterEach(async () => { + resetOpenClawConfigCoordinatorForTests(); + if (previousConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + } + vi.restoreAllMocks(); + await rm(testDir, { recursive: true, force: true }); + }); + + it('mutates the running Gateway snapshot and commits it with its base hash', async () => { + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockImplementation(async (method: string) => { + if (method === 'config.get') { + return { raw: '{ feature: { enabled: false } }', hash: 'hash-1' }; + } + if (method === 'config.set') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }); + registerOpenClawConfigCoordinator(gatewayManager); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + + const changed = await mutateOpenClawConfig((config) => { + (config.feature as Record).enabled = true; + }); + + expect(changed).toBe(true); + expect(gatewayManager.rpc).toHaveBeenNthCalledWith(1, 'config.get', {}); + expect(gatewayManager.rpc).toHaveBeenNthCalledWith(2, 'config.set', { + raw: expect.any(String), + baseHash: 'hash-1', + }); + const setParams = gatewayManager.rpc.mock.calls[1][1] as { raw: string }; + expect(JSON.parse(setParams.raw)).toEqual({ feature: { enabled: true } }); + expect(gatewayManager.restart).not.toHaveBeenCalled(); + expect(killSpy).not.toHaveBeenCalled(); + }); + + it('reads the running Gateway snapshot instead of a stale local file', async () => { + await writeFile(configPath, '{ localOnly: true }\n', 'utf8'); + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockResolvedValueOnce({ + raw: '{ gatewayOnly: true }', + hash: 'hash-1', + }); + registerOpenClawConfigCoordinator(gatewayManager); + + await expect(readOpenClawConfigSnapshot()).resolves.toEqual({ + config: { gatewayOnly: true }, + exists: true, + }); + expect(gatewayManager.rpc).toHaveBeenCalledOnce(); + expect(gatewayManager.rpc).toHaveBeenCalledWith('config.get', {}); + }); + + it('reads JSON5 from the resolved file while the Gateway is stopped', async () => { + await writeFile(configPath, '{\n // OpenClaw accepts JSON5\n value: 1,\n}\n', 'utf8'); + registerOpenClawConfigCoordinator(createGatewayManager('stopped')); + + await expect(readOpenClawConfigSnapshot()).resolves.toEqual({ + config: { value: 1 }, + exists: true, + }); + }); + + it('applies an awaited nested mutation to the same in-flight config without deadlocking', async () => { + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockImplementation(async (method: string) => { + if (method === 'config.get') return { raw: '{}', hash: 'hash-1' }; + if (method === 'config.set') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }); + registerOpenClawConfigCoordinator(gatewayManager); + + const mutation = mutateOpenClawConfig(async (config) => { + config.outer = 'before'; + const nestedChanged = await mutateOpenClawConfig((nestedConfig) => { + expect(nestedConfig).toBe(config); + nestedConfig.inner = true; + }); + expect(nestedChanged).toBe(true); + config.outer = 'after'; + }); + let timeout: NodeJS.Timeout | undefined; + + try { + const result = await Promise.race([ + mutation, + new Promise<'timeout'>((resolve) => { + timeout = setTimeout(() => resolve('timeout'), 100); + }), + ]); + expect(result).toBe(true); + } finally { + if (timeout) clearTimeout(timeout); + } + + expect(gatewayManager.rpc.mock.calls.map(([method]) => method)).toEqual([ + 'config.get', + 'config.set', + ]); + expect(JSON.parse((gatewayManager.rpc.mock.calls[1][1] as { raw: string }).raw)).toEqual({ + outer: 'after', + inner: true, + }); + }); + + it.each(['stopped', 'starting'] as const)( + 'uses the resolved file under the shared fallback when the Gateway is %s', + async (state) => { + await writeFile(configPath, '{\n // OpenClaw accepts JSON5\n value: 1,\n}\n', 'utf8'); + const gatewayManager = createGatewayManager(state); + registerOpenClawConfigCoordinator(gatewayManager); + + await mutateOpenClawConfig((config) => { + config.value = Number(config.value) + 1; + }); + + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ value: 2 }); + expect(gatewayManager.rpc).not.toHaveBeenCalled(); + expect(gatewayManager.restart).not.toHaveBeenCalled(); + }, + ); + + it('uses file fallback without starting a Gateway when no manager is registered', async () => { + await mutateOpenClawConfig((config) => { + config.createdBeforeRegistration = true; + }); + + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ + createdBeforeRegistration: true, + }); + }); + + it('switches to a fresh running RPC transaction when the Gateway starts before file commit', async () => { + await writeFile(configPath, '{ "value": 1, "source": "file" }\n', 'utf8'); + const gatewayManager = createGatewayManager('stopped'); + gatewayManager.getStatus + .mockReturnValueOnce({ state: 'stopped' }) + .mockReturnValue({ state: 'running' }); + gatewayManager.rpc.mockImplementation(async (method: string) => { + if (method === 'config.get') { + return { raw: '{ value: 10, source: "gateway" }', hash: 'hash-running' }; + } + if (method === 'config.set') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }); + registerOpenClawConfigCoordinator(gatewayManager); + + await mutateOpenClawConfig((config) => { + config.value = Number(config.value) + 1; + }); + + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ value: 1, source: 'file' }); + expect(gatewayManager.rpc.mock.calls.map(([method]) => method)).toEqual([ + 'config.get', + 'config.set', + ]); + expect(JSON.parse((gatewayManager.rpc.mock.calls[1][1] as { raw: string }).raw)).toEqual({ + value: 11, + source: 'gateway', + }); + }); + + it('retries from fresh file content when an external writer changes the fallback baseline', async () => { + await writeFile(configPath, '{ cli: false, ours: false }\n', 'utf8'); + const gatewayManager = createGatewayManager('stopped'); + registerOpenClawConfigCoordinator(gatewayManager); + let mutatorCalls = 0; + + await mutateOpenClawConfig(async (config) => { + mutatorCalls += 1; + if (mutatorCalls === 1) { + await writeFile(configPath, '{ cli: true, ours: false }\n', 'utf8'); + } + config.ours = true; + }); + + expect(mutatorCalls).toBe(2); + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ cli: true, ours: true }); + }); + + it('fails after one external file conflict retry without overwriting the external write', async () => { + await writeFile(configPath, '{ "external": 0 }\n', 'utf8'); + const gatewayManager = createGatewayManager('stopped'); + registerOpenClawConfigCoordinator(gatewayManager); + let mutatorCalls = 0; + + await expect(mutateOpenClawConfig(async (config) => { + mutatorCalls += 1; + await writeFile(configPath, `{ "external": ${mutatorCalls} }\n`, 'utf8'); + config.ours = true; + })).rejects.toThrow('OpenClaw config changed during file mutation'); + + expect(mutatorCalls).toBe(2); + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ external: 2 }); + }); + + it('atomically replaces the fallback file with a same-directory temporary file', async () => { + await writeFile(configPath, '{ value: 1 }\n', 'utf8'); + const gatewayManager = createGatewayManager('stopped'); + registerOpenClawConfigCoordinator(gatewayManager); + + await mutateOpenClawConfig((config) => { + config.value = 2; + }); + + expect(renameMock).toHaveBeenCalledOnce(); + const [temporaryPath, destinationPath] = renameMock.mock.calls[0] as [string, string]; + expect(temporaryPath).toMatch(new RegExp(`^${configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.`)); + expect(destinationPath).toBe(configPath); + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ value: 2 }); + }); + + it('serializes concurrent read-modify-write transactions', async () => { + const gatewayManager = createGatewayManager(); + let currentConfig: Record = {}; + let currentHash = 'hash-1'; + let releaseFirstCommit: (() => void) | undefined; + const firstCommitGate = new Promise((resolve) => { + releaseFirstCommit = resolve; + }); + let commitCount = 0; + + gatewayManager.rpc.mockImplementation(async (method: string, params: unknown) => { + if (method === 'config.get') { + return { raw: JSON.stringify(currentConfig), hash: currentHash }; + } + if (method === 'config.set') { + commitCount += 1; + if (commitCount === 1) await firstCommitGate; + currentConfig = JSON.parse((params as { raw: string }).raw) as Record; + currentHash = `hash-${commitCount + 1}`; + return { ok: true }; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + registerOpenClawConfigCoordinator(gatewayManager); + + const first = mutateOpenClawConfig((config) => { + config.first = true; + }); + const second = mutateOpenClawConfig((config) => { + config.second = true; + }); + + await vi.waitFor(() => { + expect(gatewayManager.rpc.mock.calls.filter(([method]) => method === 'config.get')).toHaveLength(1); + expect(gatewayManager.rpc.mock.calls.filter(([method]) => method === 'config.set')).toHaveLength(1); + }); + releaseFirstCommit?.(); + await Promise.all([first, second]); + + expect(currentConfig).toEqual({ first: true, second: true }); + expect(gatewayManager.rpc.mock.calls.map(([method]) => method)).toEqual([ + 'config.get', + 'config.set', + 'config.get', + 'config.set', + ]); + }); + + it('retries one base-hash conflict from a fresh Gateway snapshot', async () => { + const gatewayManager = createGatewayManager(); + gatewayManager.rpc + .mockResolvedValueOnce({ raw: '{ value: 1 }', hash: 'hash-1' }) + .mockRejectedValueOnce(new Error('config changed since last load; re-run config.get and retry')) + .mockResolvedValueOnce({ raw: '{ value: 4 }', hash: 'hash-2' }) + .mockResolvedValueOnce({ ok: true }); + registerOpenClawConfigCoordinator(gatewayManager); + + await mutateOpenClawConfig((config) => { + config.value = Number(config.value) + 1; + }); + + expect(gatewayManager.rpc.mock.calls.map(([method]) => method)).toEqual([ + 'config.get', + 'config.set', + 'config.get', + 'config.set', + ]); + expect(gatewayManager.rpc.mock.calls[1][1]).toMatchObject({ baseHash: 'hash-1' }); + expect(gatewayManager.rpc.mock.calls[3][1]).toMatchObject({ baseHash: 'hash-2' }); + expect(JSON.parse((gatewayManager.rpc.mock.calls[3][1] as { raw: string }).raw)).toEqual({ value: 5 }); + }); + + it('does not commit a no-op mutation', async () => { + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockResolvedValueOnce({ raw: '{ value: 1 }', hash: 'hash-1' }); + registerOpenClawConfigCoordinator(gatewayManager); + + const changed = await mutateOpenClawConfig((config) => { + config.value = 1; + }); + + expect(changed).toBe(false); + expect(gatewayManager.rpc).toHaveBeenCalledOnce(); + expect(gatewayManager.rpc).toHaveBeenCalledWith('config.get', {}); + }); + + it.each([ + undefined, + {}, + { raw: '', hash: 'hash-1' }, + { raw: '{}', hash: '' }, + ])('fails closed on an incomplete running Gateway snapshot: %j', async (snapshot) => { + await writeFile(configPath, '{ "persisted": true }\n', 'utf8'); + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockResolvedValueOnce(snapshot); + registerOpenClawConfigCoordinator(gatewayManager); + + await expect(mutateOpenClawConfig((config) => { + config.persisted = false; + })).rejects.toThrow('Gateway config.get returned an incomplete config snapshot'); + + expect(gatewayManager.rpc).toHaveBeenCalledOnce(); + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ persisted: true }); + }); + + it('round-trips OpenClaw redacted placeholders when mutating an unrelated running field', async () => { + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockImplementation(async (method: string) => { + if (method === 'config.get') { + return { + raw: '{ provider: { apiKey: "__OPENCLAW_REDACTED__" }, enabled: false }', + hash: 'hash-redacted', + }; + } + if (method === 'config.set') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }); + registerOpenClawConfigCoordinator(gatewayManager); + + await mutateOpenClawConfig((config) => { + config.enabled = true; + }); + + expect(JSON.parse((gatewayManager.rpc.mock.calls[1][1] as { raw: string }).raw)).toEqual({ + provider: { apiKey: '__OPENCLAW_REDACTED__' }, + enabled: true, + }); + }); + + it('uses the runtime-shaped config snapshot when OpenClaw also returns source-shaped raw', async () => { + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockImplementation(async (method: string) => { + if (method === 'config.get') { + return { + raw: '{ sourceOnly: true, bindingOwner: "main" }', + config: { runtimeOnly: true, bindingOwner: 'main' }, + hash: 'hash-runtime-shape', + }; + } + if (method === 'config.set') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }); + registerOpenClawConfigCoordinator(gatewayManager); + + await mutateOpenClawConfig((config) => { + config.bindingOwner = 'coder'; + }); + + expect(JSON.parse((gatewayManager.rpc.mock.calls[1][1] as { raw: string }).raw)).toEqual({ + runtimeOnly: true, + bindingOwner: 'coder', + }); + }); + + it('uses a runtime-shaped config snapshot when source-shaped raw is absent', async () => { + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockImplementation(async (method: string) => { + if (method === 'config.get') { + return { + config: { runtimeOnly: true, enabled: false }, + hash: 'hash-runtime-only', + }; + } + if (method === 'config.set') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }); + registerOpenClawConfigCoordinator(gatewayManager); + + await mutateOpenClawConfig((config) => { + config.enabled = true; + }); + + expect(JSON.parse((gatewayManager.rpc.mock.calls[1][1] as { raw: string }).raw)).toEqual({ + runtimeOnly: true, + enabled: true, + }); + }); + + it.each(['config.get', 'config.set'] as const)( + 'fails closed when running %s fails', + async (failedMethod) => { + await writeFile(configPath, '{ "persisted": true }\n', 'utf8'); + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockImplementation(async (method: string) => { + if (method === failedMethod) throw new Error(`${method} unavailable`); + if (method === 'config.get') return { raw: '{ persisted: true }', hash: 'hash-1' }; + return { ok: true }; + }); + registerOpenClawConfigCoordinator(gatewayManager); + + await expect(mutateOpenClawConfig((config) => { + config.persisted = false; + })).rejects.toThrow(`${failedMethod} unavailable`); + + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ persisted: true }); + expect(gatewayManager.restart).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/tests/unit/gateway-event-dispatch.test.ts b/tests/unit/gateway-event-dispatch.test.ts index 869576f3..de0c449c 100644 --- a/tests/unit/gateway-event-dispatch.test.ts +++ b/tests/unit/gateway-event-dispatch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { dispatchProtocolEvent } from '@electron/gateway/event-dispatch'; +import { dispatchJsonRpcNotification, dispatchProtocolEvent } from '@electron/gateway/event-dispatch'; function createMockEmitter() { const emitted: Array<{ event: string; payload: unknown }> = []; @@ -68,10 +68,7 @@ describe('dispatchProtocolEvent', () => { type: 'run.ended', runId: 'run-1', })); - expect(emitter.emit).toHaveBeenCalledWith('notification', { - method: 'agent', - params: payload, - }); + expect(emitter.emit).not.toHaveBeenCalledWith('notification', expect.anything()); }); it('normalizes terminal lifecycle phases as run.ended', () => { @@ -102,7 +99,7 @@ describe('dispatchProtocolEvent', () => { }); }); - it('dispatches normalized agent runtime events alongside the legacy notification path', () => { + it('dispatches normalized agent runtime events without the unused legacy notification path', () => { const emitter = createMockEmitter(); dispatchProtocolEvent(emitter, 'agent', { runId: 'run-1', @@ -128,10 +125,29 @@ describe('dispatchProtocolEvent', () => { name: 'read', args: { filePath: '/tmp/demo.md' }, }); - expect(emitter.emit).toHaveBeenCalledWith('notification', { + expect(emitter.emit).not.toHaveBeenCalledWith('notification', expect.anything()); + }); + + it('dispatches JSON-RPC agent events only through the normalized runtime path', () => { + const emitter = createMockEmitter(); + dispatchJsonRpcNotification(emitter, { + jsonrpc: '2.0', method: 'agent', - params: expect.objectContaining({ runId: 'run-1', stream: 'tool' }), + params: { + runId: 'run-1', + sessionKey: 'agent:main:main', + stream: 'lifecycle', + seq: 1, + ts: 10, + data: { phase: 'start', startedAt: 10 }, + }, }); + + expect(emitter.emit).toHaveBeenCalledWith('chat:runtime-event', expect.objectContaining({ + type: 'run.started', + runId: 'run-1', + })); + expect(emitter.emit).not.toHaveBeenCalledWith('notification', expect.anything()); }); it('suppresses tick events', () => { diff --git a/tests/unit/gateway-events.test.ts b/tests/unit/gateway-events.test.ts index 599b0861..fb441171 100644 --- a/tests/unit/gateway-events.test.ts +++ b/tests/unit/gateway-events.test.ts @@ -7,29 +7,16 @@ const hostApiMock = vi.hoisted(() => ({ stop: vi.fn(), restart: vi.fn(), health: vi.fn(), - controlUi: vi.fn(), rpc: vi.fn(), }, - settings: { - getAll: vi.fn(), - get: vi.fn(), - set: vi.fn(), - setMany: vi.fn(), - reset: vi.fn(), - }, - logs: { - recent: vi.fn(), - dir: vi.fn(), - listFiles: vi.fn(), - readFile: vi.fn(), + sessions: { + summaries: vi.fn(), + delete: vi.fn(), + rename: vi.fn(), }, })); const hostEventSubscriptionMock = vi.fn(); -function flushAsyncImports(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -40,9 +27,11 @@ function deferred() { return { promise, resolve, reject }; } -vi.mock('@/lib/host-api', () => ({ - hostApi: hostApiMock, -})); +async function flushAsyncImports(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +vi.mock('@/lib/host-api', () => ({ hostApi: hostApiMock })); vi.mock('@/lib/host-events', () => ({ hostEvents: { @@ -63,11 +52,13 @@ describe('gateway store event wiring', () => { vi.clearAllMocks(); window.localStorage.clear(); hostApiMock.gateway.status.mockResolvedValue({ state: 'running', port: 18789 }); + hostApiMock.gateway.rpc.mockImplementation(async (method: string) => ( + method === 'sessions.list' ? { ts: 1, sessions: [] } : {} + )); + hostApiMock.sessions.summaries.mockResolvedValue({ success: true, summaries: [] }); }); - it('subscribes to typed host events on init', async () => { - hostApiMock.gateway.status.mockResolvedValueOnce({ state: 'running', port: 18789 }); - + it('keeps status, presence, channel, and catalog subscriptions out of legacy chat projections', async () => { const handlers = new Map void>(); hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); @@ -82,916 +73,63 @@ describe('gateway store event wiring', () => { expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:notification', expect.any(Function)); expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:health', expect.any(Function)); expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:presence', expect.any(Function)); - expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:chat-message', expect.any(Function)); - expect(hostEventSubscriptionMock).toHaveBeenCalledWith('chat:runtime-event', expect.any(Function)); expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function)); + expect(hostEventSubscriptionMock).not.toHaveBeenCalledWith('gateway:chat-message', expect.any(Function)); + expect(hostEventSubscriptionMock).not.toHaveBeenCalledWith('chat:runtime-event', expect.any(Function)); handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 }); expect(useGatewayStore.getState().status.state).toBe('stopped'); - handlers.get('gateway:health')?.({ ok: true, ts: 1 }); expect(useGatewayStore.getState().health?.openclawHealth).toEqual({ ok: true, ts: 1 }); - handlers.get('gateway:presence')?.([{ mode: 'gateway', ts: 2 }]); expect(useGatewayStore.getState().health?.presence).toEqual([{ mode: 'gateway', ts: 2 }]); }); - it('propagates gatewayReady field from status events', async () => { - hostApiMock.gateway.status.mockResolvedValueOnce({ state: 'running', port: 18789, gatewayReady: false }); - + it('subscribes and force-hydrates once for each ready runtime identity', async () => { const handlers = new Map void>(); hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - // Initially gatewayReady=false from the status fetch - expect(useGatewayStore.getState().status.gatewayReady).toBe(false); - - // Simulate gateway.ready event setting gatewayReady=true - handlers.get('gateway:status')?.({ state: 'running', port: 18789, gatewayReady: true }); - expect(useGatewayStore.getState().status.gatewayReady).toBe(true); - }); - - it('treats undefined gatewayReady as ready for backwards compatibility', async () => { - hostApiMock.gateway.status.mockResolvedValueOnce({ state: 'running', port: 18789 }); - - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - const status = useGatewayStore.getState().status; - // gatewayReady is undefined (old gateway version) — should be treated as ready - expect(status.gatewayReady).toBeUndefined(); - expect(status.state === 'running' && status.gatewayReady !== false).toBe(true); - }); - - it('does not clear chat sending state on non-terminal runtime events', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - sending: true, - activeRunId: 'run-1', - pendingFinal: true, - lastUserMessageAt: 1773281731000, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'tool.completed', - runId: 'run-1', - sessionKey: 'agent:main:main', - toolCallId: 'call-1', - name: 'read', - result: { summary: 'done' }, - isError: false, - }); - await flushAsyncImports(); - - expect(loadHistory).not.toHaveBeenCalled(); - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().activeRunId).toBe('run-1'); - expect(useChatStore.getState().pendingFinal).toBe(true); - expect(useChatStore.getState().lastUserMessageAt).toBe(1773281731000); - expect(useChatStore.getState().streamingTools).toEqual([]); - expect(useChatStore.getState().runtimeRuns['run-1']?.events).toEqual([ - expect.objectContaining({ type: 'tool.completed', toolCallId: 'call-1', name: 'read' }), - ]); - }); - - it('does not let a stale send RPC re-arm a completed run after a newer send starts', async () => { - let now = 1773281731000; - const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); - const firstSend = deferred<{ runId?: string }>(); - const secondSend = deferred<{ runId?: string }>(); - const sendPromises = [firstSend.promise, secondSend.promise]; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method === 'chat.send') return sendPromises.shift(); - return Promise.resolve({}); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - }); - - const first = useChatStore.getState().sendMessage('first image request'); - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().lastUserMessageAt).toBe(1773281731000); - - // History/media delivery can prove the first run is complete before the - // blocking chat.send RPC returns. The composer is then allowed to send a - // second turn; the late first ack must not overwrite that newer lifecycle. - useChatStore.setState({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingToolImages: [], - }); - now = 1773281732000; - const second = useChatStore.getState().sendMessage('second prompt'); - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().lastUserMessageAt).toBe(1773281732000); - - firstSend.resolve({ runId: 'run-first' }); - await first; - expect(useChatStore.getState().activeRunId).not.toBe('run-first'); - expect(useChatStore.getState().lastUserMessageAt).toBe(1773281732000); - - secondSend.resolve({ runId: 'run-second' }); - await second; - expect(useChatStore.getState().activeRunId).toBe('run-second'); - - nowSpy.mockRestore(); - }); - - it('preserves a running session lifecycle when creating a new chat and switching back', async () => { - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1773281731555); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:a', - sessions: [{ key: 'agent:main:a' }], - messages: [{ role: 'user', content: 'run in a' }], - sending: true, - activeRunId: 'run-a', - pendingFinal: false, - lastUserMessageAt: 1773281731000, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingToolImages: [], - loadHistory, - }); - - useChatStore.getState().newSession(); - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-1773281731555'); - expect(useChatStore.getState().sending).toBe(false); - - useChatStore.getState().switchSession('agent:main:a'); - - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().activeRunId).toBe('run-a'); - expect(useChatStore.getState().messages).toEqual([{ role: 'user', content: 'run in a' }]); - nowSpy.mockRestore(); - }); - - it('retains inactive-session runtime events for graph reconstruction after switching back', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:a', - sessions: [{ key: 'agent:main:a' }, { key: 'agent:main:b' }], - messages: [{ role: 'user', content: 'run in a' }], - sending: true, - activeRunId: 'run-a', - pendingFinal: false, - lastUserMessageAt: 1773281731000, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingToolImages: [], - loadHistory, - }); - useChatStore.getState().switchSession('agent:main:b'); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'tool.started', - runId: 'run-a', - sessionKey: 'agent:main:a', - toolCallId: 'call-read', - name: 'read', - args: { path: '/tmp/input.txt' }, - }); - await flushAsyncImports(); - - expect(useChatStore.getState().currentSessionKey).toBe('agent:main:b'); - expect(useChatStore.getState().runtimeRuns['run-a']?.events).toEqual([ - expect.objectContaining({ type: 'tool.started', toolCallId: 'call-read', name: 'read' }), - ]); - - useChatStore.getState().switchSession('agent:main:a'); - - expect(useChatStore.getState().activeRunId).toBe('run-a'); - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().runtimeRuns['run-a']?.events).toEqual([ - expect.objectContaining({ type: 'tool.started', toolCallId: 'call-read', name: 'read' }), - ]); - }); - - it('clears cached inactive-session run state when run.ended arrives while another session is selected', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:a', - sessions: [{ key: 'agent:main:a' }, { key: 'agent:main:b' }], - messages: [{ role: 'user', content: 'run in a' }], - sending: true, - activeRunId: 'run-a', - pendingFinal: true, - lastUserMessageAt: 1773281731000, - streamingText: '', - streamingMessage: null, - streamingTools: [], - pendingToolImages: [], - loadHistory, - }); - useChatStore.getState().switchSession('agent:main:b'); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-a', - sessionKey: 'agent:main:a', - status: 'completed', - endedAt: 1773281732000, - }); - await flushAsyncImports(); - - useChatStore.getState().switchSession('agent:main:a'); - - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - expect(useChatStore.getState().runtimeRuns['run-a']?.status).toBe('completed'); - }); - - it('clears chat sending state on terminal run.ended runtime event', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - sending: true, - activeRunId: 'run-2', - pendingFinal: true, - lastUserMessageAt: 1773281731000, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-2', - sessionKey: 'agent:main:main', - status: 'completed', - endedAt: 123, - }); - await flushAsyncImports(); - - expect(loadHistory).toHaveBeenCalledTimes(1); - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - expect(useChatStore.getState().pendingFinal).toBe(false); - expect(useChatStore.getState().lastUserMessageAt).toBeNull(); - }); - - it('does not clear the active send when a stale run.ended arrives for the same session', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - sending: true, - activeRunId: 'run-active', - pendingFinal: true, - lastUserMessageAt: 1773281731000, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-stale', - sessionKey: 'agent:main:main', - status: 'completed', - endedAt: 123, - }); - await flushAsyncImports(); - - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().activeRunId).toBe('run-active'); - expect(useChatStore.getState().pendingFinal).toBe(true); - expect(useChatStore.getState().lastUserMessageAt).toBe(1773281731000); - }); - - it('ignores session-less runtime terminals that do not match the active run', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - sending: true, - activeRunId: 'run-active', - pendingFinal: true, - lastUserMessageAt: 1773281731000, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-background', - status: 'completed', - endedAt: 123, - }); - await flushAsyncImports(); - - expect(loadHistory).not.toHaveBeenCalled(); - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().activeRunId).toBe('run-active'); - expect(useChatStore.getState().pendingFinal).toBe(true); - }); - - it('tracks a current-session run.started even when the optimistic send is already active', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - sending: true, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: 1773281731000, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'run.started', - runId: 'run-started-before-rpc-return', - sessionKey: 'agent:main:main', - startedAt: 1773281731001, - }); - await flushAsyncImports(); - - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().activeRunId).toBe('run-started-before-rpc-return'); - }); - - it('forces a terminal history reload when the runtime emits run.ended', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - sending: true, - activeRunId: 'run-terminal-refresh', - pendingFinal: true, - lastUserMessageAt: 1773281731000, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'tool.completed', - runId: 'run-terminal-refresh', - sessionKey: 'agent:main:main', - toolCallId: 'call-2', - name: 'grep', - result: { summary: 'done' }, - isError: false, - }); - await flushAsyncImports(); - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-terminal-refresh', - sessionKey: 'agent:main:main', - status: 'completed', - endedAt: 456, - }); - await flushAsyncImports(); - - expect(loadHistory).toHaveBeenCalledTimes(1); - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - }); - - it('forwards normalized chat runtime events through the dedicated host event channel', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - - const { useChatStore } = await import('@/stores/chat'); - const handleRuntimeEvent = vi.fn(); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - activeRunId: 'run-runtime', - handleRuntimeEvent, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'tool.started', - runId: 'run-runtime', - sessionKey: 'agent:main:main', - toolCallId: 'call-1', - name: 'read', - args: { filePath: '/tmp/demo.md' }, - }); - await flushAsyncImports(); - - expect(handleRuntimeEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: 'tool.started', - runId: 'run-runtime', - toolCallId: 'call-1', - })); - expect(loadHistory).not.toHaveBeenCalled(); - - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-runtime', - sessionKey: 'agent:main:main', - status: 'completed', - endedAt: 123, - }); - await flushAsyncImports(); - - expect(handleRuntimeEvent).toHaveBeenCalledWith(expect.objectContaining({ - type: 'run.ended', - runId: 'run-runtime', - status: 'completed', - })); - expect(loadHistory).toHaveBeenCalledTimes(1); - }); - - it('passes progressive delta notifications without seq through to chat store', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - - const { useChatStore } = await import('@/stores/chat'); - const handleChatEvent = vi.fn(); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - handleChatEvent, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('gateway:chat-message')?.({ - message: { - runId: 'run-no-seq', - sessionKey: 'agent:main:main', - state: 'delta', - message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] }, - }, - }); - handlers.get('gateway:chat-message')?.({ - message: { - runId: 'run-no-seq', - sessionKey: 'agent:main:main', - state: 'delta', - message: { role: 'assistant', content: [{ type: 'text', text: 'first second' }] }, - }, - }); - await flushAsyncImports(); - - expect(handleChatEvent).toHaveBeenCalledTimes(2); - expect(handleChatEvent.mock.calls[0]?.[0]).toMatchObject({ - runId: 'run-no-seq', - state: 'delta', - message: { content: [{ text: 'first' }] }, - }); - expect(handleChatEvent.mock.calls[1]?.[0]).toMatchObject({ - runId: 'run-no-seq', - state: 'delta', - message: { content: [{ text: 'first second' }] }, - }); - }); - - it('dedupes exact replayed delta notifications without seq', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - - const { useChatStore } = await import('@/stores/chat'); - const handleChatEvent = vi.fn(); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - handleChatEvent, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - const replayedDelta = { - message: { - runId: 'run-no-seq-replay', - sessionKey: 'agent:main:main', - state: 'delta', - message: { role: 'assistant', content: [{ type: 'text', text: 'same' }] }, - }, + const firstEpoch = { + state: 'running' as const, + port: 18789, + pid: 10, + connectedAt: 100, + gatewayReady: true, }; - - handlers.get('gateway:chat-message')?.(replayedDelta); - handlers.get('gateway:chat-message')?.(replayedDelta); - await flushAsyncImports(); - - expect(handleChatEvent).toHaveBeenCalledTimes(1); - }); - - it('renders a cron run live when its run-scoped events bind to the base cron session in view', async () => { - const baseKey = 'agent:product:cron:294717ee-6dde-45a8-8f67-900e2831cc4f'; - const runKey = `${baseKey}:run:0bfbc08a-7582-4c88-9fd3-47c484e17660`; - - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: baseKey, - sessions: [{ key: baseKey }], - messages: [{ role: 'user', content: '[cron:294717ee 早报] 执行ai-news-summarizer' }], - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - runtimeRuns: {}, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'run.started', - runId: 'run-cron', - sessionKey: runKey, - startedAt: 1, - }); - await flushAsyncImports(); - - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().activeRunId).toBe('run-cron'); - expect(loadHistory).toHaveBeenCalledTimes(1); - - handlers.get('chat:runtime-event')?.({ - type: 'tool.started', - runId: 'run-cron', - sessionKey: runKey, - toolCallId: 'call-1', - name: 'web_search', - args: { query: 'AI news' }, - }); - await flushAsyncImports(); - - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().runtimeRuns['run-cron']?.events).toContainEqual( - expect.objectContaining({ type: 'tool.started', toolCallId: 'call-1', name: 'web_search' }), - ); - - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-cron', - sessionKey: runKey, - status: 'completed', - endedAt: 2, - }); - await flushAsyncImports(); - - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - expect(loadHistory).toHaveBeenCalledTimes(2); - }); - - it('adopts an in-progress cron run when joining mid-flight without a run.started event', async () => { - const baseKey = 'agent:main:cron:job-cron-midflight'; - const runKey = `${baseKey}:run:session-mid`; - - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: baseKey, - sessions: [{ key: baseKey }], - messages: [{ role: 'user', content: '[cron:job-cron-midflight] write a doc' }], - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - runtimeRuns: {}, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - // First event the renderer sees for this session is a mid-run tool event - // (run.started was emitted before the user opened the cron session). - handlers.get('chat:runtime-event')?.({ - type: 'tool.completed', - runId: 'run-cron-mid', - sessionKey: runKey, - toolCallId: 'call-read', - name: 'read', - result: { summary: 'SKILL.md' }, - isError: false, - }); - await flushAsyncImports(); - - expect(useChatStore.getState().sending).toBe(true); - expect(useChatStore.getState().activeRunId).toBe('run-cron-mid'); - expect(useChatStore.getState().runtimeRuns['run-cron-mid']?.events).toContainEqual( - expect.objectContaining({ type: 'tool.completed', toolCallId: 'call-read' }), - ); - - // The run still settles when the terminal event finally arrives. - handlers.get('chat:runtime-event')?.({ - type: 'run.ended', - runId: 'run-cron-mid', - sessionKey: runKey, - status: 'completed', - endedAt: 10, - }); - await flushAsyncImports(); - - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - }); - - it('does not adopt a background :main inbound run from a mid-flight tool event', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - runtimeRuns: {}, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'tool.completed', - runId: 'run-inbound', - sessionKey: 'agent:main:main', - toolCallId: 'call-x', - name: 'read', - result: { summary: 'done' }, - isError: false, - }); - await flushAsyncImports(); - - // Background inbound runs on the main session must not flip into a tracked - // "Thinking" state from a stray tool event. - expect(useChatStore.getState().sending).toBe(false); - expect(useChatStore.getState().activeRunId).toBeNull(); - }); - - it('does not surface a Thinking state for background :main heartbeat runs', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - - const { useChatStore } = await import('@/stores/chat'); - const loadHistory = vi.fn(async () => {}); - useChatStore.setState({ - currentSessionKey: 'agent:main:main', - sessions: [{ key: 'agent:main:main' }], - messages: [], - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - runtimeRuns: {}, - loadHistory, - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - - handlers.get('chat:runtime-event')?.({ - type: 'run.started', - runId: 'run-heartbeat', - sessionKey: 'agent:main:main', - startedAt: 1, - }); - await flushAsyncImports(); - - // The background heartbeat must not flip the composer into a "Thinking" - // (sending) state — that gate is what suppresses the indicator. - expect(useChatStore.getState().sending).toBe(false); - }); - - it('subscribes and force-hydrates exactly once for each ready runtime identity', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const firstEpoch = { state: 'running' as const, port: 18789, pid: 10, connectedAt: 100, gatewayReady: true }; hostApiMock.gateway.status.mockResolvedValue(firstEpoch); - hostApiMock.gateway.rpc.mockImplementation(async (method: string) => { - if (method === 'sessions.subscribe') return {}; - if (method === 'sessions.list') return { ts: 1, sessions: [] }; - return {}; - }); const { useGatewayStore } = await import('@/stores/gateway'); await useGatewayStore.getState().init(); await vi.waitFor(() => { - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.list')).toHaveLength(1); + expect(hostApiMock.gateway.rpc.mock.calls.some(([method]) => method === 'sessions.list')).toBe(true); }); + const initialSubscribeCalls = hostApiMock.gateway.rpc.mock.calls.filter( + ([method]) => method === 'sessions.subscribe', + ).length; + const initialListCalls = hostApiMock.gateway.rpc.mock.calls.filter( + ([method]) => method === 'sessions.list', + ).length; handlers.get('gateway:status')?.(firstEpoch); handlers.get('gateway:status')?.({ ...firstEpoch, pid: 11, connectedAt: 200 }); await vi.waitFor(() => { - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.subscribe')).toHaveLength(2); - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.list')).toHaveLength(2); + expect(hostApiMock.gateway.rpc.mock.calls.filter( + ([method]) => method === 'sessions.subscribe', + )).toHaveLength(initialSubscribeCalls + 1); + expect(hostApiMock.gateway.rpc.mock.calls.filter( + ([method]) => method === 'sessions.list', + )).toHaveLength(initialListCalls + 1); }); }); - it('hydrates after subscribe failure and retries subscription only in the next epoch', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const firstEpoch = { state: 'running' as const, port: 18789, pid: 10, connectedAt: 100 }; - hostApiMock.gateway.status.mockResolvedValue(firstEpoch); - let subscribeCalls = 0; - hostApiMock.gateway.rpc.mockImplementation(async (method: string) => { - if (method === 'sessions.subscribe') { - subscribeCalls += 1; - if (subscribeCalls === 1) throw new Error('subscribe unavailable'); - return {}; - } - if (method === 'sessions.list') return { ts: subscribeCalls, sessions: [] }; - return {}; - }); - - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - await vi.waitFor(() => { - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.list')).toHaveLength(1); - }); - - handlers.get('gateway:status')?.(firstEpoch); - await flushAsyncImports(); - expect(subscribeCalls).toBe(1); - - handlers.get('gateway:status')?.({ ...firstEpoch, connectedAt: 101 }); - await vi.waitFor(() => expect(subscribeCalls).toBe(2)); - }); - - it('queues a new epoch hydration behind an ordinary in-flight list and fences the old response', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const firstList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method === 'sessions.subscribe') return Promise.resolve({}); - if (method === 'sessions.list') { - listCalls += 1; - if (listCalls === 1) return firstList.promise; - return Promise.resolve({ ts: 20, sessions: [{ key: 'agent:main:main', status: 'done' }] }); - } - return Promise.resolve({}); - }); - - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ sessions: [], currentSessionKey: 'agent:main:main' }); - const ordinaryLoad = useChatStore.getState().loadSessions(); - - hostApiMock.gateway.status.mockResolvedValue({ - state: 'running', port: 18789, pid: 20, connectedAt: 200, gatewayReady: true, - }); - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - await vi.waitFor(() => { - expect(hostApiMock.gateway.rpc.mock.calls.some(([method]) => method === 'sessions.subscribe')).toBe(true); - }); - await flushAsyncImports(); - - firstList.resolve({ ts: 10, sessions: [{ key: 'agent:main:old', status: 'running' }] }); - await ordinaryLoad; - await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useChatStore.getState().sessions).toEqual([expect.objectContaining({ key: 'agent:main:main', status: 'done' })]); - }); - it('routes sessions.changed through the generic notification handler', async () => { const handlers = new Map void>(); hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); - hostApiMock.gateway.rpc.mockImplementation(async (method: string) => ( - method === 'sessions.list' ? { ts: 1, sessions: [] } : {} - )); const { useChatStore } = await import('@/stores/chat'); const handleSessionsChanged = vi.fn(); useChatStore.setState({ handleSessionsChanged }); @@ -1007,231 +145,44 @@ describe('gateway store event wiring', () => { expect(handleSessionsChanged).toHaveBeenCalledWith({ key: 'agent:main:main', ts: 2, status: 'running', }); - expect(hostEventSubscriptionMock).not.toHaveBeenCalledWith('gateway:sessions-changed', expect.any(Function)); }); - it('buffers events during an ordinary list and publishes only the transaction final row', async () => { - const list = deferred>(); - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method === 'sessions.list') return list.promise; - return Promise.resolve({}); - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'running', hasActiveRun: true }], - currentSessionKey: 'agent:main:main', - }); - useSessionAttentionStore.setState({ - bySessionKey: { 'agent:main:main': { observedBusy: true, unread: false } }, - visibleSessionKey: null, - }); - const publishedStatuses: Array = []; - const unsubscribe = useChatStore.subscribe((state) => { - publishedStatuses.push(state.sessions[0]?.status); - }); - - const loading = useChatStore.getState().loadSessions(); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', - ts: 11, - session: { key: 'agent:main:main', status: 'running', hasActiveRun: true }, - }); - list.resolve({ ts: 10, sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }] }); - await loading; - unsubscribe(); - - expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'running', hasActiveRun: true }); - expect(publishedStatuses).not.toContain('done'); - expect(useSessionAttentionStore.getState().bySessionKey['agent:main:main']).toEqual({ - observedBusy: true, - unread: true, - }); - }); - - it.each(['success', 'failure'] as const)( - 'folds buffered busy to idle transactionally after list %s', - async (outcome) => { - const firstList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - if (listCalls === 1) return firstList.promise; - return Promise.resolve({ ts: 20, sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }] }); - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - currentSessionKey: 'agent:main:main', - }); - useSessionAttentionStore.setState({ bySessionKey: {}, visibleSessionKey: null }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', ts: 11, status: 'running', hasActiveRun: true, - }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', ts: 12, status: 'done', hasActiveRun: false, - }); - if (outcome === 'success') { - firstList.resolve({ ts: 10, sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }] }); - } else { - firstList.reject(new Error('list failed')); - } - await loading; - - expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'done', hasActiveRun: false }); - expect(useSessionAttentionStore.getState().bySessionKey['agent:main:main']).toEqual({ - observedBusy: false, - unread: true, - }); - if (outcome === 'failure') expect(listCalls).toBe(2); - }, - ); - - it('does not speculatively merge unorderable buffered events and schedules one forced follow-up', async () => { - const firstList = deferred>(); - const secondList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - if (listCalls === 1) return firstList.promise; - return secondList.promise; - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'running', hasActiveRun: true }], - currentSessionKey: 'agent:main:main', - }); - useSessionAttentionStore.setState({ - bySessionKey: { 'agent:main:main': { observedBusy: true, unread: false } }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', status: 'running', hasActiveRun: true, - }); - firstList.resolve({ sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }] }); - await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useSessionAttentionStore.getState().bySessionKey['agent:main:main']).toEqual({ - observedBusy: true, - unread: false, - }); - secondList.resolve({ - ts: 20, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - await loading; - - expect(listCalls).toBe(2); - expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'done', hasActiveRun: false }); - expect(useSessionAttentionStore.getState().bySessionKey['agent:main:main']).toEqual({ - observedBusy: false, - unread: true, - }); - }); - - it('preserves attention for an unorderable event after list failure and retries once', async () => { + it('queues a new generation behind an in-flight list and fences the old response', async () => { const firstList = deferred>(); let listCalls = 0; hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - if (listCalls === 1) return firstList.promise; - return Promise.resolve({ ts: 20, sessions: [{ key: 'agent:main:main', status: 'running', hasActiveRun: true }] }); - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'running', hasActiveRun: true }], - currentSessionKey: 'agent:main:main', - }); - useSessionAttentionStore.setState({ - bySessionKey: { 'agent:main:main': { observedBusy: true, unread: false } }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', status: 'done', hasActiveRun: false, - }); - firstList.reject(new Error('list failed')); - await loading; - - expect(listCalls).toBe(2); - expect(useSessionAttentionStore.getState().bySessionKey['agent:main:main']).toEqual({ - observedBusy: true, - unread: false, - }); - }); - - it('resets event timestamp fences for a new numeric Gateway generation', async () => { - hostApiMock.gateway.rpc.mockResolvedValue({ - ts: 10, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - currentSessionKey: 'agent:main:main', - }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', ts: 100, status: 'running', hasActiveRun: true, - }); - await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 2 }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', ts: 20, status: 'running', hasActiveRun: true, - }); - - expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'running', hasActiveRun: true }); - }); - - it('advances row fences to list.ts before accepting delayed standalone events', async () => { - hostApiMock.gateway.rpc.mockResolvedValue({ - ts: 100, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'running', hasActiveRun: true }], - currentSessionKey: 'agent:main:main', - }); - - await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', ts: 50, status: 'running', hasActiveRun: true, - }); - - expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'done', hasActiveRun: false }); - }); - - it('retains current-generation events while an old list and the new subscription are pending', async () => { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const oldList = deferred>(); - const subscribe = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method === 'sessions.subscribe') return subscribe.promise; + if (method === 'sessions.subscribe') return Promise.resolve({}); if (method === 'sessions.list') { listCalls += 1; - if (listCalls === 1) return oldList.promise; - return Promise.resolve({ - ts: 10, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); + return listCalls === 1 + ? firstList.promise + : Promise.resolve({ ts: 20, sessions: [{ key: 'agent:main:main', status: 'done' }] }); } return Promise.resolve({}); }); + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ sessions: [], currentSessionKey: 'agent:main:main' }); + const ordinaryLoad = useChatStore.getState().loadSessions(); + hostApiMock.gateway.status.mockResolvedValue({ + state: 'running', port: 18789, pid: 20, connectedAt: 200, gatewayReady: true, + }); + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + firstList.resolve({ ts: 10, sessions: [{ key: 'agent:main:old', status: 'running' }] }); + await ordinaryLoad; + await vi.waitFor(() => expect(listCalls).toBe(2)); + expect(useChatStore.getState().sessions).toEqual([ + expect.objectContaining({ key: 'agent:main:main', status: 'done' }), + ]); + }); + + it('folds buffered session transitions transactionally into attention state', async () => { + const list = deferred>(); + hostApiMock.gateway.rpc.mockImplementation((method: string) => ( + method === 'sessions.list' ? list.promise : Promise.resolve({}) + )); const { useChatStore } = await import('@/stores/chat'); const { useSessionAttentionStore } = await import('@/stores/session-attention'); useChatStore.setState({ @@ -1239,129 +190,190 @@ describe('gateway store event wiring', () => { currentSessionKey: 'agent:main:main', }); useSessionAttentionStore.setState({ bySessionKey: {}, visibleSessionKey: null }); - const oldLoad = useChatStore.getState().loadSessions(); - hostApiMock.gateway.status.mockResolvedValue({ - state: 'running', port: 18789, pid: 30, connectedAt: 300, gatewayReady: true, + const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + useChatStore.getState().handleSessionsChanged({ + key: 'agent:main:main', ts: 11, status: 'running', hasActiveRun: true, }); - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - await vi.waitFor(() => { - expect(hostApiMock.gateway.rpc.mock.calls.some(([method]) => method === 'sessions.subscribe')).toBe(true); + useChatStore.getState().handleSessionsChanged({ + key: 'agent:main:main', ts: 12, status: 'done', hasActiveRun: false, }); + list.resolve({ ts: 10, sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }] }); + await loading; - handlers.get('gateway:notification')?.({ - method: 'sessions.changed', - params: { - key: 'agent:main:main', - ts: 11, - session: { key: 'agent:main:main', status: 'running', hasActiveRun: true }, - }, - }); - handlers.get('gateway:notification')?.({ - method: 'sessions.changed', - params: { - key: 'agent:main:main', - ts: 12, - session: { key: 'agent:main:main', status: 'done', hasActiveRun: false }, - }, - }); - await flushAsyncImports(); - - oldList.resolve({ - ts: 5, - sessions: [{ key: 'agent:main:old', status: 'running', hasActiveRun: true }], - }); - await oldLoad; - expect(listCalls).toBe(1); - - subscribe.resolve({}); - await vi.waitFor(() => expect(listCalls).toBe(2)); - await vi.waitFor(() => { - expect(useSessionAttentionStore.getState().bySessionKey['agent:main:main']).toEqual({ - observedBusy: false, - unread: true, - }); - }); expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'done', hasActiveRun: false }); + expect(useSessionAttentionStore.getState().bySessionKey['agent:main:main']).toEqual({ + observedBusy: false, + unread: true, + }); }); - it('folds finite per-key transitions when another buffered key is unorderable', async () => { - const firstList = deferred>(); + it('keeps activity monotonic and cleans metadata on sessions.changed deletion', async () => { + const changedKey = 'agent:changed:main'; + const unrelatedKey = 'agent:unrelated:main'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + sessions: [ + { key: changedKey, updatedAt: 1_700_000_000_100 }, + { key: unrelatedKey, updatedAt: 1_700_000_001_100 }, + ], + currentSessionKey: changedKey, + sessionLabels: { [changedKey]: 'Changed', [unrelatedKey]: 'Unrelated' }, + sessionLastActivity: { + [changedKey]: 1_700_000_000_900, + [unrelatedKey]: 1_700_000_001_900, + }, + }); + + useChatStore.getState().handleSessionsChanged({ + key: changedKey, + ts: 10, + session: { key: changedKey, updatedAt: 1_700_000_000_200, status: 'running' }, + }); + expect(useChatStore.getState().sessionLastActivity[changedKey]).toBe(1_700_000_000_900); + + useChatStore.getState().handleSessionsChanged({ + sessionKey: changedKey, + reason: 'delete', + ts: 11, + }); + expect(useChatStore.getState().sessionLabels).toEqual({ [unrelatedKey]: 'Unrelated' }); + expect(useChatStore.getState().sessionLastActivity).toEqual({ + [unrelatedKey]: 1_700_000_001_900, + }); + }); + + it('selects a valid fallback when a standalone event deletes the current session', async () => { + const deletedKey = 'agent:main:current'; + const fallbackKey = 'agent:main:fallback'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + sessions: [{ key: deletedKey }, { key: fallbackKey, updatedAt: 10 }], + currentSessionKey: deletedKey, + currentAgentId: 'main', + sessionLabels: { [deletedKey]: 'Deleted', [fallbackKey]: 'Fallback' }, + sessionLastActivity: { [deletedKey]: 20, [fallbackKey]: 10 }, + }); + + useChatStore.getState().handleSessionsChanged({ + sessionKey: deletedKey, + reason: 'delete', + ts: 11, + }); + + expect(useChatStore.getState()).toMatchObject({ + currentSessionKey: fallbackKey, + currentAgentId: 'main', + sessions: [{ key: fallbackKey, updatedAt: 10 }], + sessionLabels: { [fallbackKey]: 'Fallback' }, + sessionLastActivity: { [fallbackKey]: 10 }, + }); + }); + + it('selects a valid fallback when an exact update hides the current session', async () => { + const hiddenKey = 'agent:main:feishu:current'; + const fallbackKey = 'agent:main:fallback'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + sessions: [ + { key: hiddenKey, lastMessagePreview: 'real message' }, + { key: fallbackKey, updatedAt: 10 }, + ], + currentSessionKey: hiddenKey, + currentAgentId: 'main', + sessionLabels: { [hiddenKey]: 'Channel', [fallbackKey]: 'Fallback' }, + sessionLastActivity: { [hiddenKey]: 20, [fallbackKey]: 10 }, + }); + + useChatStore.getState().handleSessionsChanged({ + key: hiddenKey, + ts: 11, + session: { key: hiddenKey, lastMessagePreview: null }, + }); + + expect(useChatStore.getState()).toMatchObject({ + currentSessionKey: fallbackKey, + sessions: [{ key: fallbackKey, updatedAt: 10 }], + sessionLabels: { [fallbackKey]: 'Fallback' }, + sessionLastActivity: { [fallbackKey]: 10 }, + }); + }); + + it('selects a fallback during failed-list exact-current deletion reduction', async () => { + const failedList = deferred>(); const retryList = deferred>(); let listCalls = 0; hostApiMock.gateway.rpc.mockImplementation((method: string) => { if (method !== 'sessions.list') return Promise.resolve({}); listCalls += 1; - return listCalls === 1 ? firstList.promise : retryList.promise; + return listCalls === 1 + ? failedList.promise + : retryList.promise; }); const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - const keyA = 'agent:main:session-a'; - const keyB = 'agent:main:session-b'; useChatStore.setState({ - sessions: [ - { key: keyA, status: 'done', hasActiveRun: false }, - { key: keyB, status: 'running', hasActiveRun: true }, - ], - currentSessionKey: keyB, - }); - useSessionAttentionStore.setState({ - bySessionKey: { [keyB]: { observedBusy: true, unread: false } }, - visibleSessionKey: null, + sessions: [{ key: 'agent:main:current' }, { key: 'agent:main:fallback', updatedAt: 10 }], + currentSessionKey: 'agent:main:current', + currentAgentId: 'main', + sessionLabels: { 'agent:main:current': 'Current', 'agent:main:fallback': 'Fallback' }, + sessionLastActivity: {}, }); const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); useChatStore.getState().handleSessionsChanged({ - key: keyA, ts: 11, status: 'running', hasActiveRun: true, + sessionKey: 'agent:main:current', reason: 'delete', ts: 11, }); - useChatStore.getState().handleSessionsChanged({ - key: keyB, status: 'done', hasActiveRun: false, - }); - useChatStore.getState().handleSessionsChanged({ - key: keyA, ts: 12, status: 'done', hasActiveRun: false, - }); - firstList.resolve({ - ts: 10, - sessions: [ - { key: keyA, status: 'done', hasActiveRun: false }, - { key: keyB, status: 'done', hasActiveRun: false }, - ], - }); - - await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useSessionAttentionStore.getState().bySessionKey[keyA]).toEqual({ - observedBusy: false, - unread: true, - }); - expect(useSessionAttentionStore.getState().bySessionKey[keyB]).toEqual({ - observedBusy: true, - unread: false, - }); - - retryList.resolve({ - ts: 20, - sessions: [ - { key: keyA, status: 'done', hasActiveRun: false }, - { key: keyB, status: 'running', hasActiveRun: true }, - ], + failedList.reject(new Error('list failed')); + await vi.waitFor(() => { + expect(useChatStore.getState().currentSessionKey).toBe('agent:main:fallback'); }); + expect(useChatStore.getState().sessions.some( + (session) => session.key === 'agent:main:current', + )).toBe(false); + retryList.resolve({ ts: 20, sessions: [{ key: 'agent:main:fallback', updatedAt: 10 }] }); await loading; + expect(listCalls).toBe(2); + expect(useChatStore.getState().sessions.some( + (session) => session.key === 'agent:main:current', + )).toBe(false); }); - it('forces one post-flight recovery for a buffered partial event', async () => { - const firstList = deferred>(); - const recoveryList = deferred>(); + it('recovers an untimestamped event with one forced catalog reload', async () => { + hostApiMock.gateway.rpc.mockResolvedValue({ + ts: 20, + sessions: [{ key: 'agent:main:main', status: 'done' }], + }); + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + sessions: [{ key: 'agent:main:main', status: 'running' }], + currentSessionKey: 'agent:main:main', + }); + + useChatStore.getState().handleSessionsChanged({ + key: 'agent:main:main', status: 'done', + }); + + await vi.waitFor(() => expect(hostApiMock.gateway.rpc).toHaveBeenCalledWith( + 'sessions.list', + { includeDerivedTitles: true, includeLastMessage: true }, + undefined, + )); + await vi.waitFor(() => expect(useChatStore.getState().sessions[0]?.status).toBe('done')); + }); + + it('reduces finite events after list failure and retries the catalog once', async () => { + const failedList = deferred>(); + const retryList = deferred>(); let listCalls = 0; hostApiMock.gateway.rpc.mockImplementation((method: string) => { if (method !== 'sessions.list') return Promise.resolve({}); listCalls += 1; - return listCalls === 1 ? firstList.promise : recoveryList.promise; + return listCalls === 1 ? failedList.promise : retryList.promise; }); const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], + sessions: [{ key: 'agent:main:main', status: 'done' }], currentSessionKey: 'agent:main:main', }); @@ -1369,822 +381,165 @@ describe('gateway store event wiring', () => { useChatStore.getState().handleSessionsChanged({ key: 'agent:main:main', ts: 11, status: 'running', hasActiveRun: true, }); - firstList.resolve({ - ts: 10, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - - await vi.waitFor(() => expect(listCalls).toBe(2)); - recoveryList.resolve({ - ts: 12, - sessions: [{ key: 'agent:main:main', status: 'running', hasActiveRun: true }], - }); - await loading; - expect(listCalls).toBe(2); - }); - - it('forces throttled recovery for a standalone mismatched snapshot', async () => { - const recoveryList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - if (listCalls === 1) { - return Promise.resolve({ - ts: 10, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - } - return recoveryList.promise; - }); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - currentSessionKey: 'agent:main:main', - }); - await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', - ts: 11, - session: { key: 'agent:main:other', status: 'running', hasActiveRun: true }, - }); - await vi.waitFor(() => expect(listCalls).toBe(2)); - - recoveryList.resolve({ - ts: 12, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - await flushAsyncImports(); - await flushAsyncImports(); - expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'done', hasActiveRun: false }); - expect(listCalls).toBe(2); - }); - - it('preserves uncertain exact-key attention while reducing finite events after list failure', async () => { - const failedList = deferred>(); - const retryList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - return listCalls === 1 ? failedList.promise : retryList.promise; - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - const sessionKey = 'agent:main:uncertain'; - useChatStore.setState({ - sessions: [{ key: sessionKey, status: 'done', hasActiveRun: false }], - currentSessionKey: sessionKey, - }); - useSessionAttentionStore.setState({ - bySessionKey: { [sessionKey]: { observedBusy: true, unread: false } }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, ts: 11, status: 'running', hasActiveRun: true, - }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, status: 'done', hasActiveRun: false, - }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, ts: 12, status: 'done', hasActiveRun: false, - }); failedList.reject(new Error('list failed')); + await vi.waitFor(() => expect(useChatStore.getState().sessions[0]).toMatchObject({ + status: 'running', hasActiveRun: true, + })); await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'done', hasActiveRun: false }); - expect(useSessionAttentionStore.getState().bySessionKey[sessionKey]).toEqual({ - observedBusy: true, - unread: false, - }); - retryList.resolve({ ts: 20, - sessions: [{ key: sessionKey, status: 'running', hasActiveRun: true }], - }); - await loading; - expect(listCalls).toBe(2); - }); - - it('folds an unrelated reliable key after list failure while preserving uncertain attention', async () => { - const failedList = deferred>(); - const retryList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - return listCalls === 1 ? failedList.promise : retryList.promise; - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - const uncertainKey = 'agent:main:uncertain'; - const reliableKey = 'agent:main:reliable'; - useChatStore.setState({ - sessions: [ - { key: uncertainKey, status: 'done', hasActiveRun: false }, - { key: reliableKey, status: 'done', hasActiveRun: false }, - ], - currentSessionKey: uncertainKey, - }); - useSessionAttentionStore.setState({ - bySessionKey: { [uncertainKey]: { observedBusy: true, unread: false } }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - key: uncertainKey, ts: 11, status: 'running', hasActiveRun: true, - }); - useChatStore.getState().handleSessionsChanged({ - key: uncertainKey, status: 'done', hasActiveRun: false, - }); - useChatStore.getState().handleSessionsChanged({ - key: uncertainKey, ts: 12, status: 'done', hasActiveRun: false, - }); - useChatStore.getState().handleSessionsChanged({ - key: reliableKey, ts: 13, status: 'running', hasActiveRun: true, - }); - useChatStore.getState().handleSessionsChanged({ - key: reliableKey, ts: 14, status: 'done', hasActiveRun: false, - }); - failedList.reject(new Error('list failed')); - - await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useSessionAttentionStore.getState().bySessionKey[uncertainKey]).toEqual({ - observedBusy: true, - unread: false, - }); - expect(useSessionAttentionStore.getState().bySessionKey[reliableKey]).toEqual({ - observedBusy: false, - unread: true, - }); - - retryList.resolve({ - ts: 20, - sessions: [ - { key: uncertainKey, status: 'running', hasActiveRun: true }, - { key: reliableKey, status: 'done', hasActiveRun: false }, - ], - }); - await loading; - expect(listCalls).toBe(2); - }); - - it('preserves all attention for unscoped failed-list uncertainty while reducing catalog rows', async () => { - const failedList = deferred>(); - const retryList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - return listCalls === 1 ? failedList.promise : retryList.promise; - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - const sessionKey = 'agent:main:reliable'; - useChatStore.setState({ - sessions: [{ key: sessionKey, status: 'done', hasActiveRun: false }], - currentSessionKey: sessionKey, - }); - useSessionAttentionStore.setState({ - bySessionKey: { [sessionKey]: { observedBusy: false, unread: true } }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, ts: 11, status: 'running', hasActiveRun: true, - }); - useChatStore.getState().handleSessionsChanged({ status: 'done', hasActiveRun: false }); - failedList.reject(new Error('list failed')); - - await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useChatStore.getState().sessions[0]).toMatchObject({ status: 'running', hasActiveRun: true }); - expect(useSessionAttentionStore.getState().bySessionKey[sessionKey]).toEqual({ - observedBusy: false, - unread: true, - }); - - retryList.resolve({ - ts: 20, - sessions: [{ key: sessionKey, status: 'running', hasActiveRun: true }], - }); - await loading; - expect(listCalls).toBe(2); - }); - - it('consumes a forced reload queued during list-flight settlement', async () => { - const firstList = deferred>(); - const recoveryList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - return listCalls === 1 ? firstList.promise : recoveryList.promise; - }); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - currentSessionKey: 'agent:main:main', - }); - let queued = false; - const unsubscribe = useChatStore.subscribe(() => { - if (queued) return; - queued = true; - queueMicrotask(() => { - useChatStore.getState().handleSessionsChanged({ - key: 'agent:main:main', - ts: 11, - session: { key: 'agent:main:other', status: 'running', hasActiveRun: true }, - }); - }); - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - firstList.resolve({ - ts: 10, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - - await vi.waitFor(() => expect(listCalls).toBe(2)); - recoveryList.resolve({ - ts: 12, sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], }); await loading; - unsubscribe(); - expect(listCalls).toBe(2); + + expect(useChatStore.getState().sessions[0]).toMatchObject({ + status: 'done', hasActiveRun: false, + }); }); - it('rejects a stale standalone insertion for a key absent from the successful list', async () => { - hostApiMock.gateway.rpc.mockResolvedValue({ - ts: 100, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - currentSessionKey: 'agent:main:main', - }); - await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - - useChatStore.getState().handleSessionsChanged({ - key: 'agent:stale:main', - ts: 99, - session: { key: 'agent:stale:main', status: 'running', hasActiveRun: true }, - }); - - expect(useChatStore.getState().sessions.some((session) => session.key === 'agent:stale:main')).toBe(false); - }); - - it('allows equal and newer standalone insertions after a successful list', async () => { - hostApiMock.gateway.rpc.mockResolvedValue({ - ts: 100, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - currentSessionKey: 'agent:main:main', - }); - await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - - useChatStore.getState().handleSessionsChanged({ - key: 'agent:equal:main', - ts: 100, - session: { key: 'agent:equal:main', status: 'running', hasActiveRun: true }, - }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:newer:main', - ts: 101, - session: { key: 'agent:newer:main', status: 'running', hasActiveRun: true }, - }); - - expect(useChatStore.getState().sessions.map((session) => session.key)).toEqual(expect.arrayContaining([ - 'agent:equal:main', - 'agent:newer:main', - ])); - }); - - it('resets the successful-list timestamp floor for a new Gateway generation', async () => { + it('resets the standalone timestamp floor across Gateway generations', async () => { let listCalls = 0; hostApiMock.gateway.rpc.mockImplementation(async (method: string) => { if (method !== 'sessions.list') return {}; listCalls += 1; return { ts: listCalls === 1 ? 100 : 10, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], + sessions: [{ key: 'agent:main:main', status: 'done' }], }; }); const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - currentSessionKey: 'agent:main:main', - }); await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 2 }); useChatStore.getState().handleSessionsChanged({ - key: 'agent:reset:main', + key: 'agent:main:new', ts: 20, - session: { key: 'agent:reset:main', status: 'running', hasActiveRun: true }, + session: { key: 'agent:main:new', status: 'running' }, }); - expect(useChatStore.getState().sessions.some((session) => session.key === 'agent:reset:main')).toBe(true); + expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({ + key: 'agent:main:new', status: 'running', + })); }); - it('keeps standalone activity monotonic for changed and unrelated rows and still cleans deletion', async () => { - const changedKey = 'agent:changed:main'; - const unrelatedKey = 'agent:unrelated:main'; - const changedActivity = 1_700_000_000_900; - const unrelatedActivity = 1_700_000_001_900; - const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [ - { key: changedKey, updatedAt: 1_700_000_000_100 }, - { key: unrelatedKey, updatedAt: 1_700_000_001_100 }, - ], - currentSessionKey: changedKey, - sessionLastActivity: { - [changedKey]: changedActivity, - [unrelatedKey]: unrelatedActivity, - }, - }); - - useChatStore.getState().handleSessionsChanged({ - key: changedKey, - ts: 10, - session: { key: changedKey, updatedAt: 1_700_000_000_200, status: 'running' }, - }); - - expect(useChatStore.getState().sessionLastActivity).toMatchObject({ - [changedKey]: changedActivity, - [unrelatedKey]: unrelatedActivity, - }); - - useChatStore.getState().handleSessionsChanged({ - sessionKey: changedKey, - reason: 'delete', - ts: 11, - }); - expect(useChatStore.getState().sessionLastActivity[changedKey]).toBeUndefined(); - expect(useChatStore.getState().sessionLastActivity[unrelatedKey]).toBe(unrelatedActivity); - }); - - it('keeps failed-list activity monotonic while reducing older catalog timestamps', async () => { - const failedList = deferred>(); - const retryList = deferred>(); - const changedKey = 'agent:changed:main'; - const unrelatedKey = 'agent:unrelated:main'; - const changedActivity = 1_700_000_000_900; - const unrelatedActivity = 1_700_000_001_900; + it('runs a forced reload queued during in-flight settlement', async () => { + const firstList = deferred>(); let listCalls = 0; hostApiMock.gateway.rpc.mockImplementation((method: string) => { if (method !== 'sessions.list') return Promise.resolve({}); listCalls += 1; - return listCalls === 1 ? failedList.promise : retryList.promise; + return listCalls === 1 + ? firstList.promise + : Promise.resolve({ ts: 20, sessions: [{ key: 'agent:main:new' }] }); }); const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [ - { key: changedKey, updatedAt: 1_700_000_000_100 }, - { key: unrelatedKey, updatedAt: 1_700_000_001_100 }, - ], - currentSessionKey: changedKey, - sessionLastActivity: { - [changedKey]: changedActivity, - [unrelatedKey]: unrelatedActivity, - }, - }); - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - key: changedKey, - ts: 10, - session: { key: changedKey, updatedAt: 1_700_000_000_200, status: 'running' }, - }); - failedList.reject(new Error('list failed')); + const first = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + const forced = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + firstList.resolve({ ts: 10, sessions: [{ key: 'agent:main:old' }] }); + await Promise.all([first, forced]); - await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useChatStore.getState().sessionLastActivity).toMatchObject({ - [changedKey]: changedActivity, - [unrelatedKey]: unrelatedActivity, - }); - - retryList.resolve({ - ts: 20, - sessions: [ - { key: changedKey, updatedAt: 1_700_000_000_300 }, - { key: unrelatedKey, updatedAt: 1_700_000_001_300 }, - ], - }); - await loading; + expect(listCalls).toBe(2); + expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({ + key: 'agent:main:new', + })); }); - it('periodic reconciliation restores subscription after a missed running event', async () => { - vi.useFakeTimers(); - try { - const handlers = new Map void>(); - hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { - handlers.set(eventName, handler); - return () => {}; - }); - const running = { - state: 'running' as const, port: 18789, pid: 40, connectedAt: 400, gatewayReady: true, - }; - hostApiMock.gateway.status.mockResolvedValue(running); - hostApiMock.gateway.rpc.mockImplementation(async (method: string) => ( - method === 'sessions.list' ? { ts: 1, sessions: [] } : {} - )); - await import('@/stores/chat'); - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - await vi.advanceTimersByTimeAsync(0); - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.subscribe')).toHaveLength(1); - - handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 }); - expect(useGatewayStore.getState().status.state).toBe('stopped'); - - hostApiMock.gateway.status.mockResolvedValue(running); - await vi.advanceTimersByTimeAsync(30_000); - await vi.advanceTimersByTimeAsync(0); - - expect(useGatewayStore.getState().status).toMatchObject({ state: 'running', pid: 40, connectedAt: 400 }); - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.subscribe')).toHaveLength(2); - } finally { - vi.clearAllTimers(); - vi.useRealTimers(); - } - }); - - it('periodic reconciliation subscribes once for a same-state new identity and ignores duplicates', async () => { - vi.useFakeTimers(); - try { - const firstIdentity = { - state: 'running' as const, port: 18789, pid: 50, connectedAt: 500, gatewayReady: true, - }; - hostApiMock.gateway.status.mockResolvedValue(firstIdentity); - hostApiMock.gateway.rpc.mockImplementation(async (method: string) => ( - method === 'sessions.list' ? { ts: 1, sessions: [] } : {} - )); - await import('@/stores/chat'); - const { useGatewayStore } = await import('@/stores/gateway'); - await useGatewayStore.getState().init(); - await vi.advanceTimersByTimeAsync(0); - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.subscribe')).toHaveLength(1); - - const secondIdentity = { ...firstIdentity, pid: 51, connectedAt: 501 }; - hostApiMock.gateway.status.mockResolvedValue(secondIdentity); - await vi.advanceTimersByTimeAsync(30_000); - await vi.advanceTimersByTimeAsync(0); - expect(useGatewayStore.getState().status).toMatchObject({ pid: 51, connectedAt: 501 }); - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.subscribe')).toHaveLength(2); - - await vi.advanceTimersByTimeAsync(30_000); - await vi.advanceTimersByTimeAsync(0); - expect(hostApiMock.gateway.rpc.mock.calls.filter(([method]) => method === 'sessions.subscribe')).toHaveLength(2); - } finally { - vi.clearAllTimers(); - vi.useRealTimers(); - } - }); - - it('applies the successful-list floor while reducing buffered events after list failure', async () => { - const failedList = deferred>(); - const retryList = deferred>(); - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - if (listCalls === 1) { - return Promise.resolve({ - ts: 100, - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - }); - } - return listCalls === 2 ? failedList.promise : retryList.promise; + it('rejects stale standalone insertion below the successful-list floor', async () => { + hostApiMock.gateway.rpc.mockResolvedValue({ + ts: 100, + sessions: [{ key: 'agent:main:main' }], }); const { useChatStore } = await import('@/stores/chat'); - useChatStore.setState({ - sessions: [{ key: 'agent:main:main', status: 'done', hasActiveRun: false }], - currentSessionKey: 'agent:main:main', - }); await useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + useChatStore.getState().handleSessionsChanged({ + key: 'agent:main:stale', + ts: 50, + session: { key: 'agent:main:stale', status: 'running' }, + }); + + expect(useChatStore.getState().sessions.some( + (session) => session.key === 'agent:main:stale', + )).toBe(false); + }); + + it('replays complete delete/recreate ordering into row metadata and attention', async () => { + const list = deferred>(); + const key = 'agent:main:recreated'; + hostApiMock.gateway.rpc.mockImplementation((method: string) => ( + method === 'sessions.list' ? list.promise : Promise.resolve({}) + )); + const { useChatStore } = await import('@/stores/chat'); + const { useSessionAttentionStore } = await import('@/stores/session-attention'); + useChatStore.setState({ + sessions: [{ key, label: 'Old', updatedAt: 1_000 }], + currentSessionKey: key, + sessionLabels: { [key]: 'Old' }, + sessionLastActivity: { [key]: 1_000 }, + }); + useSessionAttentionStore.setState({ + bySessionKey: { [key]: { observedBusy: false, unread: false } }, + visibleSessionKey: null, + }); + const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + useChatStore.getState().handleSessionsChanged({ sessionKey: key, reason: 'delete', ts: 11 }); useChatStore.getState().handleSessionsChanged({ - key: 'agent:stale:main', - ts: 99, - session: { key: 'agent:stale:main', status: 'running', hasActiveRun: true }, + key, + ts: 12, + session: { key, label: 'New', updatedAt: 2_000, status: 'running', hasActiveRun: true }, }); useChatStore.getState().handleSessionsChanged({ - key: 'agent:equal:main', - ts: 100, - session: { key: 'agent:equal:main', status: 'running', hasActiveRun: true }, + key, + ts: 13, + session: { key, label: 'New', updatedAt: 3_000, status: 'done', hasActiveRun: false }, }); - useChatStore.getState().handleSessionsChanged({ - key: 'agent:newer:main', - ts: 101, - session: { key: 'agent:newer:main', status: 'running', hasActiveRun: true }, - }); - failedList.reject(new Error('list failed')); - - await vi.waitFor(() => expect(listCalls).toBe(3)); - const sessionKeys = useChatStore.getState().sessions.map((session) => session.key); - expect(sessionKeys).not.toContain('agent:stale:main'); - expect(sessionKeys).toEqual(expect.arrayContaining(['agent:equal:main', 'agent:newer:main'])); - - retryList.reject(new Error('retry failed')); + list.resolve({ ts: 10, sessions: [{ key, label: 'Old', updatedAt: 1_000 }] }); await loading; + + expect(useChatStore.getState().sessions.find((session) => session.key === key)).toMatchObject({ + label: 'New', updatedAt: 3_000_000, status: 'done', hasActiveRun: false, + }); + expect(useChatStore.getState().sessionLabels[key]).toBe('New'); + expect(useChatStore.getState().sessionLastActivity[key]).toBe(3_000_000); + expect(useSessionAttentionStore.getState().bySessionKey[key]).toEqual({ + observedBusy: false, + unread: true, + }); + }); + + it('does not suppress a recreated row when a queued load advances generations', async () => { + const firstList = deferred>(); + const secondList = deferred>(); + let listCalls = 0; + const key = 'agent:main:recreated-generation'; + hostApiMock.gateway.rpc.mockImplementation((method: string) => { + if (method !== 'sessions.list') return Promise.resolve({}); + listCalls += 1; + if (listCalls === 1) return firstList.promise; + if (listCalls === 2) return secondList.promise; + return Promise.resolve({ ts: 20, sessions: [{ key, label: 'Recreated' }] }); + }); + const { useChatStore } = await import('@/stores/chat'); + + const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); + const sameGenerationReload = useChatStore.getState().loadSessions({ + force: true, + gatewayGeneration: 1, + }); + useChatStore.getState().handleSessionsChanged({ sessionKey: key, reason: 'delete', ts: 11 }); + firstList.resolve({ ts: 10, sessions: [{ key, label: 'Old' }] }); + await vi.waitFor(() => expect(listCalls).toBe(2)); + const nextGeneration = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 2 }); + secondList.resolve({ ts: 12, sessions: [{ key, label: 'Old' }] }); + await Promise.all([loading, sameGenerationReload, nextGeneration]); + expect(listCalls).toBe(3); - }); - - it('cleans exact deleted metadata and attention during failed-list reduction', async () => { - const failedList = deferred>(); - const retryList = deferred>(); - const deletedKey = 'agent:deleted:main'; - const unrelatedKey = 'agent:unrelated:main'; - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - return listCalls === 1 ? failedList.promise : retryList.promise; - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - useChatStore.setState({ - sessions: [{ key: deletedKey }, { key: unrelatedKey }], - currentSessionKey: unrelatedKey, - sessionLabels: { - [deletedKey]: 'Deleted label', - [unrelatedKey]: 'Unrelated label', - }, - sessionLastActivity: { - [deletedKey]: 1_700_000_009_000, - [unrelatedKey]: 1_700_000_010_000, - }, - }); - useSessionAttentionStore.setState({ - bySessionKey: { - [deletedKey]: { observedBusy: false, unread: true }, - [unrelatedKey]: { observedBusy: true, unread: false }, - }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - sessionKey: deletedKey, - reason: 'delete', - ts: 10, - }); - failedList.reject(new Error('list failed')); - - await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useChatStore.getState().sessions.some((session) => session.key === deletedKey)).toBe(false); - expect(useChatStore.getState().sessionLabels).toEqual({ [unrelatedKey]: 'Unrelated label' }); - expect(useChatStore.getState().sessionLastActivity).toEqual({ - [unrelatedKey]: 1_700_000_010_000, - }); - expect(useSessionAttentionStore.getState().bySessionKey[deletedKey]).toBeUndefined(); - expect(useSessionAttentionStore.getState().bySessionKey[unrelatedKey]).toEqual({ - observedBusy: true, - unread: false, - }); - - retryList.reject(new Error('retry failed')); - await loading; - expect(listCalls).toBe(2); - }); - - it('transactionally rebuilds attention and metadata for successful delete-recreate replay', async () => { - const list = deferred>(); - const sessionKey = 'agent:main:recreated'; - const unrelatedKey = 'agent:main:unrelated'; - hostApiMock.gateway.rpc.mockImplementation((method: string) => ( - method === 'sessions.list' ? list.promise : Promise.resolve({}) - )); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - useChatStore.setState({ - sessions: [{ key: sessionKey }, { key: unrelatedKey }], - currentSessionKey: sessionKey, - sessionLabels: { - [sessionKey]: 'Old label', - [unrelatedKey]: 'Unrelated label', - }, - sessionLastActivity: { - [sessionKey]: 1_700_000_009_000, - [unrelatedKey]: 1_700_000_010_000, - }, - }); - useSessionAttentionStore.setState({ - bySessionKey: { - [sessionKey]: { observedBusy: false, unread: true }, - [unrelatedKey]: { observedBusy: true, unread: false }, - }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ - sessionKey, - reason: 'delete', - ts: 11, - }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, - ts: 12, - session: { - key: sessionKey, - label: 'New label', - updatedAt: 1_700_000_001_000, - status: 'running', - hasActiveRun: true, - }, - }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, - ts: 13, - session: { - key: sessionKey, - label: 'New label', - updatedAt: 1_700_000_002_000, - status: 'done', - hasActiveRun: false, - }, - }); - list.resolve({ - ts: 10, - sessions: [ - { key: sessionKey, label: 'Old label', updatedAt: 1_700_000_000_000, status: 'done', hasActiveRun: false }, - { key: unrelatedKey }, - ], - }); - await loading; - - expect(useChatStore.getState().sessions.find((session) => session.key === sessionKey)).toMatchObject({ - label: 'New label', - updatedAt: 1_700_000_002_000, - status: 'done', - hasActiveRun: false, - }); - expect(useSessionAttentionStore.getState().bySessionKey[sessionKey]).toEqual({ - observedBusy: false, - unread: true, - }); - expect(useChatStore.getState().sessionLabels).toEqual({ - [sessionKey]: 'New label', - [unrelatedKey]: 'Unrelated label', - }); - expect(useChatStore.getState().sessionLastActivity).toEqual({ - [sessionKey]: 1_700_000_002_000, - [unrelatedKey]: 1_700_000_010_000, - }); - expect(useSessionAttentionStore.getState().bySessionKey[unrelatedKey]).toEqual({ - observedBusy: true, - unread: false, - }); - }); - - it('transactionally rebuilds attention and metadata for failed delete-recreate reduction', async () => { - const failedList = deferred>(); - const retryList = deferred>(); - const sessionKey = 'agent:main:recreated'; - const unrelatedKey = 'agent:main:unrelated'; - let listCalls = 0; - hostApiMock.gateway.rpc.mockImplementation((method: string) => { - if (method !== 'sessions.list') return Promise.resolve({}); - listCalls += 1; - return listCalls === 1 ? failedList.promise : retryList.promise; - }); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - const { - beginSessionLabelHydration, - finishSessionLabelHydration, - getSessionLabelHydrationVersion, - } = await import('@/stores/chat/session-label-hydration'); - const hydrationSession = { - key: sessionKey, - updatedAt: 1_700_000_002_000, - label: 'New label', - }; - const oldHydrationVersion = getSessionLabelHydrationVersion(hydrationSession, {}); - expect(beginSessionLabelHydration(sessionKey, oldHydrationVersion)).toBe(true); - useChatStore.setState({ - sessions: [{ key: sessionKey }, { key: unrelatedKey }], - currentSessionKey: sessionKey, - sessionLabels: { - [sessionKey]: 'Old label', - [unrelatedKey]: 'Unrelated label', - }, - sessionLastActivity: { - [sessionKey]: 1_700_000_009_000, - [unrelatedKey]: 1_700_000_010_000, - }, - }); - useSessionAttentionStore.setState({ - bySessionKey: { - [sessionKey]: { observedBusy: false, unread: true }, - [unrelatedKey]: { observedBusy: true, unread: false }, - }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ sessionKey, reason: 'delete', ts: 11 }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, - ts: 12, - session: { - key: sessionKey, - label: 'New label', - updatedAt: 1_700_000_001_000, - status: 'running', - hasActiveRun: true, - }, - }); - useChatStore.getState().handleSessionsChanged({ - key: sessionKey, - ts: 13, - session: { - key: sessionKey, - label: 'New label', - updatedAt: 1_700_000_002_000, - status: 'done', - hasActiveRun: false, - }, - }); - failedList.reject(new Error('list failed')); - - await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(useChatStore.getState().sessions.find((session) => session.key === sessionKey)).toMatchObject({ - label: 'New label', - updatedAt: 1_700_000_002_000, - status: 'done', - hasActiveRun: false, - }); - expect(useSessionAttentionStore.getState().bySessionKey[sessionKey]).toEqual({ - observedBusy: false, - unread: true, - }); - expect(useChatStore.getState().sessionLabels).toEqual({ - [sessionKey]: 'New label', - [unrelatedKey]: 'Unrelated label', - }); - expect(useChatStore.getState().sessionLastActivity).toEqual({ - [sessionKey]: 1_700_000_002_000, - [unrelatedKey]: 1_700_000_010_000, - }); - const newHydrationVersion = getSessionLabelHydrationVersion(hydrationSession, {}); - expect(newHydrationVersion).not.toBe(oldHydrationVersion); - finishSessionLabelHydration(sessionKey, oldHydrationVersion, 'error'); - expect(beginSessionLabelHydration(sessionKey, newHydrationVersion)).toBe(true); - - retryList.reject(new Error('retry failed')); - await loading; - expect(listCalls).toBe(2); - }); - - it('removes attention and metadata for successful buffered deletion without recreation', async () => { - const list = deferred>(); - const deletedKey = 'agent:main:deleted'; - const unrelatedKey = 'agent:main:unrelated'; - hostApiMock.gateway.rpc.mockImplementation((method: string) => ( - method === 'sessions.list' ? list.promise : Promise.resolve({}) - )); - const { useChatStore } = await import('@/stores/chat'); - const { useSessionAttentionStore } = await import('@/stores/session-attention'); - useChatStore.setState({ - sessions: [{ key: deletedKey }, { key: unrelatedKey }], - currentSessionKey: unrelatedKey, - sessionLabels: { [deletedKey]: 'Deleted label', [unrelatedKey]: 'Unrelated label' }, - sessionLastActivity: { [deletedKey]: 9_000, [unrelatedKey]: 10_000 }, - }); - useSessionAttentionStore.setState({ - bySessionKey: { - [deletedKey]: { observedBusy: false, unread: true }, - [unrelatedKey]: { observedBusy: true, unread: false }, - }, - visibleSessionKey: null, - }); - - const loading = useChatStore.getState().loadSessions({ force: true, gatewayGeneration: 1 }); - useChatStore.getState().handleSessionsChanged({ sessionKey: deletedKey, reason: 'delete', ts: 11 }); - list.resolve({ ts: 10, sessions: [{ key: deletedKey }, { key: unrelatedKey }] }); - await loading; - - expect(useChatStore.getState().sessions.some((session) => session.key === deletedKey)).toBe(false); - expect(useSessionAttentionStore.getState().bySessionKey[deletedKey]).toBeUndefined(); - expect(useChatStore.getState().sessionLabels).toEqual({ [unrelatedKey]: 'Unrelated label' }); - expect(useChatStore.getState().sessionLastActivity).toEqual({ [unrelatedKey]: 10_000 }); - expect(useSessionAttentionStore.getState().bySessionKey[unrelatedKey]).toEqual({ - observedBusy: true, - unread: false, - }); + expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({ + key, + label: 'Recreated', + })); }); }); diff --git a/tests/unit/gateway-manager-diagnostics.test.ts b/tests/unit/gateway-manager-diagnostics.test.ts index 67fc9bdd..8fc6a3c7 100644 --- a/tests/unit/gateway-manager-diagnostics.test.ts +++ b/tests/unit/gateway-manager-diagnostics.test.ts @@ -58,7 +58,7 @@ describe('GatewayManager diagnostics', () => { }); expect(manager.getDiagnostics().lastAliveAt).toBe(Date.now()); - const successPromise = manager.rpc<{ ok: boolean }>('chat.history', {}, 1000); + const successPromise = manager.rpc<{ ok: boolean }>('sessions.list', {}, 1000); const successRequestId = Array.from( (manager as unknown as { pendingRequests: Map }).pendingRequests.keys(), )[0]; diff --git a/tests/unit/gateway-manager-reload-policy-refresh.test.ts b/tests/unit/gateway-manager-reload-policy-refresh.test.ts deleted file mode 100644 index ed52d5b7..00000000 --- a/tests/unit/gateway-manager-reload-policy-refresh.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// @vitest-environment node - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { mockLoadGatewayReloadPolicy } = vi.hoisted(() => ({ - mockLoadGatewayReloadPolicy: vi.fn(), -})); - -vi.mock('electron', () => ({ - app: { - getPath: () => '/tmp', - isPackaged: false, - }, - utilityProcess: { - fork: vi.fn(), - }, -})); - -vi.mock('@electron/gateway/reload-policy', async () => { - const actual = await vi.importActual( - '@electron/gateway/reload-policy', - ); - return { - ...actual, - loadGatewayReloadPolicy: (...args: unknown[]) => mockLoadGatewayReloadPolicy(...args), - }; -}); - -describe('GatewayManager refreshReloadPolicy', () => { - beforeEach(() => { - vi.resetAllMocks(); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-03-15T00:00:00.000Z')); - }); - - it('deduplicates concurrent refresh calls', async () => { - const { GatewayManager } = await import('@electron/gateway/manager'); - let resolveLoad: ((value: { mode: 'reload'; debounceMs: number }) => void) | null = null; - mockLoadGatewayReloadPolicy.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveLoad = resolve; - }), - ); - - const manager = new GatewayManager(); - const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise }) - .refreshReloadPolicy.bind(manager); - - const p1 = refresh(true); - const p2 = refresh(true); - - expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1); - - resolveLoad?.({ mode: 'reload', debounceMs: 1300 }); - await Promise.all([p1, p2]); - - expect((manager as unknown as { reloadPolicy: { mode: string; debounceMs: number } }).reloadPolicy).toEqual({ - mode: 'reload', - debounceMs: 1300, - }); - }); - - it('hits TTL cache and skips refresh within window', async () => { - const { GatewayManager } = await import('@electron/gateway/manager'); - mockLoadGatewayReloadPolicy.mockResolvedValueOnce({ mode: 'restart', debounceMs: 2200 }); - - const manager = new GatewayManager(); - const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise }) - .refreshReloadPolicy.bind(manager); - - await refresh(); - expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1); - - vi.setSystemTime(new Date('2026-03-15T00:00:10.000Z')); - await refresh(); - - expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1); - }); - - it('refreshes immediately when force=true even within TTL', async () => { - const { GatewayManager } = await import('@electron/gateway/manager'); - mockLoadGatewayReloadPolicy - .mockResolvedValueOnce({ mode: 'hybrid', debounceMs: 1200 }) - .mockResolvedValueOnce({ mode: 'off', debounceMs: 9000 }); - - const manager = new GatewayManager(); - const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise }) - .refreshReloadPolicy.bind(manager); - - await refresh(); - expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1); - - vi.setSystemTime(new Date('2026-03-15T00:00:05.000Z')); - await refresh(true); - - expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(2); - expect((manager as unknown as { reloadPolicy: { mode: string; debounceMs: number } }).reloadPolicy).toEqual({ - mode: 'off', - debounceMs: 9000, - }); - }); -}); diff --git a/tests/unit/gateway-reload-policy.test.ts b/tests/unit/gateway-reload-policy.test.ts deleted file mode 100644 index 1149f39e..00000000 --- a/tests/unit/gateway-reload-policy.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { mockReadFile } = vi.hoisted(() => ({ - mockReadFile: vi.fn(), -})); - -vi.mock('node:fs/promises', () => ({ - readFile: mockReadFile, - default: { - readFile: mockReadFile, - }, -})); - -import { - DEFAULT_GATEWAY_RELOAD_POLICY, - loadGatewayReloadPolicy, - parseGatewayReloadPolicy, -} from '@electron/gateway/reload-policy'; - -describe('parseGatewayReloadPolicy', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns defaults when config is missing', () => { - expect(parseGatewayReloadPolicy(undefined)).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY); - }); - - it('parses mode and debounce from gateway.reload', () => { - const result = parseGatewayReloadPolicy({ - gateway: { - reload: { - mode: 'off', - debounceMs: 3000, - }, - }, - }); - - expect(result).toEqual({ mode: 'off', debounceMs: 3000 }); - }); - - it('normalizes invalid mode and debounce bounds', () => { - const negative = parseGatewayReloadPolicy({ - gateway: { reload: { mode: 'invalid', debounceMs: -100 } }, - }); - expect(negative).toEqual({ - mode: DEFAULT_GATEWAY_RELOAD_POLICY.mode, - debounceMs: 0, - }); - - const overMax = parseGatewayReloadPolicy({ - gateway: { reload: { mode: 'hybrid', debounceMs: 600_000 } }, - }); - expect(overMax).toEqual({ mode: 'hybrid', debounceMs: 60_000 }); - }); - - it('falls back to default mode for non-string or unknown mode values', () => { - const unknownString = parseGatewayReloadPolicy({ - gateway: { reload: { mode: 'HYBRID', debounceMs: 1200 } }, - }); - expect(unknownString.mode).toBe(DEFAULT_GATEWAY_RELOAD_POLICY.mode); - - const nonString = parseGatewayReloadPolicy({ - gateway: { reload: { mode: { value: 'reload' }, debounceMs: 1200 } }, - }); - expect(nonString.mode).toBe(DEFAULT_GATEWAY_RELOAD_POLICY.mode); - }); - - it('handles malformed gateway/reload shapes', () => { - const malformedGateway = parseGatewayReloadPolicy({ gateway: 'bad-shape' }); - expect(malformedGateway).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY); - - const malformedReload = parseGatewayReloadPolicy({ - gateway: { reload: ['bad-shape'] }, - }); - expect(malformedReload).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY); - }); - - it('normalizes debounce boundary and rounding behavior', () => { - const atMin = parseGatewayReloadPolicy({ - gateway: { reload: { mode: 'reload', debounceMs: 0 } }, - }); - expect(atMin).toEqual({ mode: 'reload', debounceMs: 0 }); - - const roundsUpToCap = parseGatewayReloadPolicy({ - gateway: { reload: { mode: 'reload', debounceMs: 60_000.5 } }, - }); - expect(roundsUpToCap).toEqual({ mode: 'reload', debounceMs: 60_000 }); - - const roundsDownAtCap = parseGatewayReloadPolicy({ - gateway: { reload: { mode: 'reload', debounceMs: 60_000.4 } }, - }); - expect(roundsDownAtCap).toEqual({ mode: 'reload', debounceMs: 60_000 }); - }); -}); - -describe('loadGatewayReloadPolicy', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns defaults when config read fails', async () => { - mockReadFile.mockRejectedValueOnce(new Error('EACCES')); - - await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY); - expect(mockReadFile).toHaveBeenCalledOnce(); - }); - - it('returns defaults when config JSON is malformed', async () => { - mockReadFile.mockResolvedValueOnce('{'); - - await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY); - }); - - it('returns defaults when config JSON has malformed shape', async () => { - mockReadFile.mockResolvedValueOnce( - JSON.stringify({ - gateway: { reload: ['malformed'] }, - }), - ); - - await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY); - }); - - it('loads config and applies invalid mode fallback', async () => { - mockReadFile.mockResolvedValueOnce( - JSON.stringify({ - gateway: { reload: { mode: 'unknown-mode', debounceMs: 1350 } }, - }), - ); - - await expect(loadGatewayReloadPolicy()).resolves.toEqual({ - mode: DEFAULT_GATEWAY_RELOAD_POLICY.mode, - debounceMs: 1350, - }); - }); - - it('loads config and keeps debounce boundary values', async () => { - mockReadFile.mockResolvedValueOnce( - JSON.stringify({ - gateway: { reload: { mode: 'restart', debounceMs: 60_000 } }, - }), - ); - - await expect(loadGatewayReloadPolicy()).resolves.toEqual({ - mode: 'restart', - debounceMs: 60_000, - }); - }); -}); diff --git a/tests/unit/gateway-rpc-backpressure.test.ts b/tests/unit/gateway-rpc-backpressure.test.ts deleted file mode 100644 index 31e5ef5a..00000000 --- a/tests/unit/gateway-rpc-backpressure.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { GatewayRpcBackpressure } from '../../electron/gateway/rpc-backpressure'; - -describe('GatewayRpcBackpressure', () => { - it('coalesces duplicate in-flight chat.history requests', async () => { - const backpressure = new GatewayRpcBackpressure({ maxConcurrentHistory: 2 }); - let resolveRunner: ((value: unknown) => void) | null = null; - const runner = vi.fn(() => new Promise((resolve) => { - resolveRunner = resolve; - })); - - const first = backpressure.run('chat.history', { sessionKey: 'agent:main:one', limit: 200 }, undefined, runner); - const second = backpressure.run('chat.history', { limit: 200, sessionKey: 'agent:main:one' }, undefined, runner); - - expect(runner).toHaveBeenCalledTimes(1); - resolveRunner?.({ messages: ['ok'] }); - await expect(first).resolves.toEqual({ messages: ['ok'] }); - await expect(second).resolves.toEqual({ messages: ['ok'] }); - }); - - it('limits distinct chat.history requests while allowing non-history RPCs through', async () => { - const backpressure = new GatewayRpcBackpressure({ maxConcurrentHistory: 2 }); - let activeHistory = 0; - let maxActiveHistory = 0; - const releaseHistory: Array<() => void> = []; - const runner = vi.fn(async (method: string) => { - if (method !== 'chat.history') { - return { method }; - } - activeHistory += 1; - maxActiveHistory = Math.max(maxActiveHistory, activeHistory); - await new Promise((resolve) => releaseHistory.push(resolve)); - activeHistory -= 1; - return { method }; - }); - - const historyRuns = Array.from({ length: 5 }, (_, index) => ( - backpressure.run('chat.history', { sessionKey: `agent:main:${index}`, limit: 200 }, undefined, runner) - )); - const statusRun = backpressure.run('status', {}, undefined, runner); - - await expect(statusRun).resolves.toEqual({ method: 'status' }); - expect(maxActiveHistory).toBe(2); - expect(backpressure.getDiagnostics()).toMatchObject({ - activeHistory: 2, - queuedHistory: 3, - }); - - while (releaseHistory.length > 0) { - releaseHistory.shift()?.(); - await Promise.resolve(); - } - - await Promise.all(historyRuns); - expect(maxActiveHistory).toBeLessThanOrEqual(2); - expect(backpressure.getDiagnostics()).toEqual({ - activeHistory: 0, - queuedHistory: 0, - inFlightHistory: 0, - }); - }); -}); diff --git a/tests/unit/gateway-rpc-test-utils.ts b/tests/unit/gateway-rpc-test-utils.ts deleted file mode 100644 index f38085b2..00000000 --- a/tests/unit/gateway-rpc-test-utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { DEFAULT_CHAT_HISTORY_MAX_CHARS } from '@/stores/chat/history-rpc-params'; - -export function chatHistoryRpcParams(sessionKey: string, limit: number) { - return { - sessionKey, - limit, - maxChars: DEFAULT_CHAT_HISTORY_MAX_CHARS, - }; -} - -type GatewayRpcMock = { - mockImplementation: ( - fn: (method: string, params?: unknown, timeoutMs?: number) => unknown, - ) => unknown; -}; - -export function installGatewayRpcDefaults(mock: GatewayRpcMock): void { - mock.mockImplementation(async (method: string) => { - if (method === 'config.get') return {}; - if (method === 'chat.history') return { messages: [] }; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); -} - -export function mockGatewayChatHistory( - mock: GatewayRpcMock, - result: Record, -): void { - mock.mockImplementation(async (method: string) => { - if (method === 'config.get') return {}; - if (method === 'chat.history') return result; - throw new Error(`Unexpected gateway RPC: ${method}`); - }); -} - -export async function prewarmChatHistoryMaxCharsCache(): Promise { - const { resetChatHistoryMaxCharsCache, resolveChatHistoryMaxChars } = await import( - '@/stores/chat/history-rpc-params' - ); - resetChatHistoryMaxCharsCache(); - await resolveChatHistoryMaxChars(); -} diff --git a/tests/unit/gateway-ws-trace.test.ts b/tests/unit/gateway-ws-trace.test.ts index 120309c5..f3912d83 100644 --- a/tests/unit/gateway-ws-trace.test.ts +++ b/tests/unit/gateway-ws-trace.test.ts @@ -22,9 +22,23 @@ describe('gateway ws trace', () => { expect(JSON.stringify(redacted)).toContain('[redacted]'); }); + it('redacts serialized config write payloads', () => { + const redacted = redactGatewayFrameForTrace({ + type: 'req', + method: 'config.set', + params: { + raw: JSON.stringify({ channels: { telegram: { botToken: 'new-secret-token' } } }), + baseHash: 'hash-1', + }, + }) as { params: { raw: string; baseHash: string } }; + + expect(redacted.params).toEqual({ raw: '[redacted]', baseHash: 'hash-1' }); + expect(JSON.stringify(redacted)).not.toContain('new-secret-token'); + }); + it('summarizes request and event frames', () => { - expect(summarizeGatewayFrameForTrace({ type: 'req', id: '1', method: 'chat.history' })) - .toEqual('req id=1 method=chat.history'); + expect(summarizeGatewayFrameForTrace({ type: 'req', id: '1', method: 'sessions.list' })) + .toEqual('req id=1 method=sessions.list'); expect(summarizeGatewayFrameForTrace({ type: 'event', event: 'chat' })) .toEqual('event chat'); }); diff --git a/tests/unit/generated-files-panel.test.tsx b/tests/unit/generated-files-panel.test.tsx deleted file mode 100644 index de03ce95..00000000 --- a/tests/unit/generated-files-panel.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { GeneratedFilesPanel } from '@/components/file-preview/GeneratedFilesPanel'; -import type { GeneratedFile } from '@/lib/generated-files'; - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (_key: string, params?: Record) => { - if (typeof params?.count === 'number') return `File changes (${params.count})`; - return ''; - }, - }), -})); - -function makeFile(overrides: Partial): GeneratedFile { - return { - filePath: '/tmp/example.ts', - fileName: 'example.ts', - ext: '.ts', - mimeType: 'text/typescript', - contentType: 'code', - action: 'modified', - fullContent: 'const value = 2\n', - lastSeenIndex: 1, - ...overrides, - }; -} - -describe('GeneratedFilesPanel', () => { - it('shows pdf and spreadsheet outputs as open-folder actions', () => { - const onOpen = vi.fn(); - const onRevealInFileManager = vi.fn(); - const windowsPath = String.raw`C:\Users\张三\Downloads\测试PDF文件-有内容.pdf`; - const file = makeFile({ - filePath: windowsPath, - fileName: '测试PDF文件-有内容.pdf', - ext: '.pdf', - mimeType: 'application/pdf', - contentType: 'document', - }); - - render( - , - ); - - const button = screen.getByRole('button', { name: /测试PDF文件-有内容\.pdf/ }); - expect(button).toBeEnabled(); - fireEvent.click(button); - expect(onOpen).not.toHaveBeenCalled(); - expect(onRevealInFileManager).toHaveBeenCalledWith(expect.objectContaining({ filePath: windowsPath })); - }); - - it('reveals xls and xlsx files with unicode Windows paths', () => { - const onOpen = vi.fn(); - const onRevealInFileManager = vi.fn(); - const files = [ - makeFile({ - filePath: String.raw`C:\Users\张三\Documents\销售报表.xls`, - fileName: '销售报表.xls', - ext: '.xls', - mimeType: 'application/vnd.ms-excel', - contentType: 'document', - }), - makeFile({ - filePath: String.raw`C:\Users\张三\Documents\财务明细.xlsx`, - fileName: '财务明细.xlsx', - ext: '.xlsx', - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - contentType: 'document', - lastSeenIndex: 2, - }), - ]; - - render( - , - ); - - fireEvent.click(screen.getByRole('button', { name: /销售报表\.xls/ })); - fireEvent.click(screen.getByRole('button', { name: /财务明细\.xlsx/ })); - - expect(onOpen).not.toHaveBeenCalled(); - expect(onRevealInFileManager).toHaveBeenNthCalledWith(1, expect.objectContaining({ filePath: files[0].filePath })); - expect(onRevealInFileManager).toHaveBeenNthCalledWith(2, expect.objectContaining({ filePath: files[1].filePath })); - }); - - it('keeps unsupported non-preview document formats non-clickable', () => { - const onOpen = vi.fn(); - render( - , - ); - - const button = screen.getByRole('button', { name: /report\.doc/ }); - expect(button).toBeDisabled(); - fireEvent.click(button); - expect(onOpen).not.toHaveBeenCalled(); - }); - - it('keeps supported text/code formats clickable', () => { - const onOpen = vi.fn(); - const file = makeFile({ filePath: '/tmp/example.ts' }); - render(); - - const button = screen.getByRole('button', { name: /example\.ts/ }); - expect(button).toBeEnabled(); - fireEvent.click(button); - expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/example.ts' })); - }); -}); diff --git a/tests/unit/generated-files.test.ts b/tests/unit/generated-files.test.ts index 34a3e7b5..6b589791 100644 --- a/tests/unit/generated-files.test.ts +++ b/tests/unit/generated-files.test.ts @@ -1,16 +1,14 @@ import { describe, expect, it } from 'vitest'; import { - computeLineStats, - extractGeneratedFiles, + basenameOf, + classifyFileExt, + extnameOf, getMimeTypeForExt, isDocxPreviewExt, isPptxPreviewExt, - supportsInlineDiff, supportsInlineDocumentPreview, - type GeneratedFile, - type GeneratedFileBaseline, + supportsRichDocumentPreview, } from '@/lib/generated-files'; -import type { RawMessage } from '@/stores/chat'; import { attachmentOpenMode, filePreviewKind, @@ -19,87 +17,36 @@ import { richFilePreviewKind, } from '@/lib/file-preview-capabilities'; -function makeWriteFile(overrides: Partial = {}): GeneratedFile { - return { - filePath: '/tmp/example.ts', - fileName: 'example.ts', - ext: '.ts', - mimeType: 'text/typescript', - contentType: 'code', - action: 'modified', - fullContent: 'const value = 2\nconsole.log(value)\n', - lastSeenIndex: 1, - ...overrides, - }; -} - describe('generated-files utilities', () => { - it('computes write line stats from an existing-file baseline', () => { - const stats = computeLineStats( - makeWriteFile({ - baseline: { status: 'ok', content: 'const value = 1\nconsole.log(value)\n' }, - }), - ); - - expect(stats).toEqual({ added: 1, removed: 1 }); - }); - - it('treats missing baseline as a new file for line stats', () => { - const stats = computeLineStats( - makeWriteFile({ - action: 'created', - baseline: { status: 'missing' }, - fullContent: 'line 1\nline 2\n', - }), - ); - - expect(stats).toEqual({ added: 2, removed: 0 }); - }); - - it('refuses to fake precise line stats when baseline is unavailable', () => { - const stats = computeLineStats( - makeWriteFile({ - baseline: { status: 'unavailable', reason: 'outsideSandbox' }, - }), - ); - - expect(stats).toBeNull(); - }); - - it('routes html documents to rendered inline preview and text diff support', () => { + it('routes text documents to rendered inline preview', () => { expect(supportsInlineDocumentPreview('.html')).toBe(true); expect(supportsInlineDocumentPreview('.htm')).toBe(true); - expect(supportsInlineDiff({ ext: '.html', contentType: 'document' })).toBe(true); + expect(supportsInlineDocumentPreview('.md')).toBe(true); }); - it('routes pdf/spreadsheet to rich-doc preview but never to text diff', () => { - expect(supportsInlineDocumentPreview('.md')).toBe(true); - // PDFs and spreadsheets now render through dedicated viewers, so they - // qualify for inline preview... + it('routes supported binary documents to rich preview', () => { expect(supportsInlineDocumentPreview('.pdf')).toBe(true); expect(supportsInlineDocumentPreview('.xlsx')).toBe(true); - // ...but diffing binary content is still meaningless, so the diff - // tab stays hidden for these formats. - expect(supportsInlineDiff({ ext: '.pdf', contentType: 'document' })).toBe(false); - expect(supportsInlineDiff({ ext: '.xlsx', contentType: 'document' })).toBe(false); - expect(supportsInlineDiff({ ext: '.doc', contentType: 'document' })).toBe(false); - expect(supportsInlineDiff({ ext: '.docx', contentType: 'document' })).toBe(false); - expect(supportsInlineDiff({ ext: '.ppt', contentType: 'document' })).toBe(false); - expect(supportsInlineDiff({ ext: '.pptx', contentType: 'document' })).toBe(false); + expect(supportsInlineDocumentPreview('.docx')).toBe(true); + expect(supportsInlineDocumentPreview('.pptx')).toBe(true); + expect(supportsRichDocumentPreview('.PDF')).toBe(true); + expect(supportsRichDocumentPreview('.xlsx')).toBe(true); + expect(supportsRichDocumentPreview('.docx')).toBe(true); + expect(supportsRichDocumentPreview('.pptx')).toBe(true); + expect(supportsRichDocumentPreview('.doc')).toBe(false); + expect(supportsRichDocumentPreview('.ppt')).toBe(false); + }); - const stats = computeLineStats({ - filePath: '/tmp/report.pdf', - fileName: 'report.pdf', - ext: '.pdf', - mimeType: 'application/pdf', - contentType: 'document', - action: 'modified', - fullContent: 'pretend text payload', - baseline: { status: 'ok', content: 'older pretend text payload' }, - lastSeenIndex: 1, - }); - - expect(stats).toBeNull(); + it('preserves extension, path, and content-type classification', () => { + expect(basenameOf(String.raw`C:\workspace\src\index.ts`)).toBe('index.ts'); + expect(extnameOf('/workspace/archive.tar.gz')).toBe('.gz'); + expect(extnameOf('/workspace/.env')).toBe(''); + expect(classifyFileExt('.png')).toBe('snapshot'); + expect(classifyFileExt('.tsx')).toBe('code'); + expect(classifyFileExt('.pdf')).toBe('document'); + expect(classifyFileExt('.mp4')).toBe('video'); + expect(classifyFileExt('.wav')).toBe('audio'); + expect(classifyFileExt('.zip')).toBe('other'); }); it('uses format-aware preview limits', () => { @@ -209,80 +156,4 @@ describe('generated-files utilities', () => { expect(getMimeTypeForExt('.docx')).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); expect(getMimeTypeForExt('.pptx')).toBe('application/vnd.openxmlformats-officedocument.presentationml.presentation'); }); - - it('extracts write files with per-run baseline state and action', () => { - const messages: RawMessage[] = [ - { role: 'user', content: 'update file', timestamp: 1 }, - { - role: 'assistant', - content: [{ - type: 'tool_use', - id: 'write-1', - name: 'Write', - input: { - file_path: '/tmp/example.ts', - content: 'const value = 2\n', - }, - }], - }, - ]; - - const baselineByPath = new Map([ - ['/tmp/example.ts', { status: 'ok', content: 'const value = 1\n' }], - ]); - - const files = extractGeneratedFiles(messages, 0, 1, (filePath) => baselineByPath.get(filePath)); - - expect(files).toHaveLength(1); - expect(files[0]).toMatchObject({ - filePath: '/tmp/example.ts', - action: 'modified', - baseline: { status: 'ok', content: 'const value = 1\n' }, - }); - }); - - it('keeps new-file writes marked as created when the baseline says missing', () => { - const messages: RawMessage[] = [ - { role: 'user', content: 'create file', timestamp: 1 }, - { - role: 'assistant', - content: [{ - type: 'tool_use', - id: 'write-1', - name: 'Write', - input: { - file_path: '/tmp/new-file.ts', - content: 'export const created = true\n', - }, - }], - }, - ]; - - const files = extractGeneratedFiles(messages, 0, 1, () => ({ status: 'missing' })); - - expect(files).toHaveLength(1); - expect(files[0]).toMatchObject({ - filePath: '/tmp/new-file.ts', - action: 'created', - baseline: { status: 'missing' }, - }); - }); - - it('computes edit snippet stats from joined edit hunks', () => { - const stats = computeLineStats({ - filePath: '/tmp/example.ts', - fileName: 'example.ts', - ext: '.ts', - mimeType: 'text/typescript', - contentType: 'code', - action: 'modified', - edits: [ - { old: 'alpha\n', new: 'beta\n' }, - { old: 'gamma\n', new: 'delta\n' }, - ], - lastSeenIndex: 1, - }); - - expect(stats).toEqual({ added: 2, removed: 2 }); - }); }); diff --git a/tests/unit/harness-specs.test.ts b/tests/unit/harness-specs.test.ts index 77f34f31..34caee1a 100644 --- a/tests/unit/harness-specs.test.ts +++ b/tests/unit/harness-specs.test.ts @@ -149,6 +149,60 @@ describe('harness specs', () => { expect(workspaceScenario?.body).toContain('single mounted PPTX viewer'); }); + it('defines the Streamdown Markdown rendering harness contract', async () => { + const referencePath = 'harness/reference/markdown-rendering.md'; + const focusedTests = [ + 'tests/unit/streamdown-config.test.tsx', + 'tests/unit/markdown-preview.test.tsx', + 'tests/unit/acp-chat-components.test.tsx', + 'tests/e2e/markdown-file-preview.spec.ts', + 'tests/e2e/chat-streamdown-rendering.spec.ts', + ]; + const [task, rules, scenarios, markdownReference] = await Promise.all([ + loadSpec('harness/specs/tasks/replace-markdown-renderer-with-streamdown.md'), + loadRuleSpecs(), + loadScenarioSpecs(), + readFile(referencePath, 'utf8'), + ]); + const markdownRule = rules.find( + (rule) => rule.data.id === 'markdown-rendering-safety-and-performance', + ); + const scenarioById = new Map(scenarios.map((scenario) => [scenario.data.id, scenario])); + const requiredTests = task.data.requiredTests as string[]; + + expect(task.data).toMatchObject({ + id: 'replace-markdown-renderer-with-streamdown', + scenario: 'acp-chat-experience', + taskType: 'runtime-bridge', + requiredProfiles: ['fast', 'e2e'], + docs: { required: true }, + }); + expect(task.data.requiredRules).toContain('markdown-rendering-safety-and-performance'); + expect(task.body).toContain(referencePath); + expect(markdownRule?.data.appliesTo).toEqual(expect.arrayContaining([ + 'acp-chat-experience', + 'chat-workspace-and-navigation', + ])); + expect(markdownRule?.body).toContain(referencePath); + expect(markdownReference).toContain('word-level'); + expect(markdownReference).toContain('before/after'); + + const scenarioTests = new Map([ + ['acp-chat-experience', 'tests/e2e/chat-streamdown-rendering.spec.ts'], + ['chat-workspace-and-navigation', 'tests/e2e/markdown-file-preview.spec.ts'], + ]); + for (const [scenarioId, testPath] of scenarioTests) { + const scenario = scenarioById.get(scenarioId); + expect(scenario?.data.requiredRules).toContain('markdown-rendering-safety-and-performance'); + expect(scenario?.data.ownedPaths).toContain(testPath); + expect(scenario?.body).toContain(referencePath); + } + for (const testPath of focusedTests) { + expect(requiredTests.join('\n')).toContain(testPath); + } + expect(requiredTests).toContain('pnpm run perf:chat'); + }); + it('keeps implemented design decisions in topic-based Harness references', async () => { const [ browserReference, @@ -256,6 +310,24 @@ describe('harness specs', () => { expect(acpChatScenario?.data.ownedPaths).toContain('tests/e2e/chat-acp-attachments.spec.ts'); }); + it('keeps the structural task example on current ACP session catalog behavior', async () => { + const example = await loadSpec('harness/specs/tasks/maintain-session-catalog-reconciliation.example.md'); + + expect(example.data).toMatchObject({ + id: 'maintain-session-catalog-reconciliation', + scenario: 'gateway-backend-communication', + taskType: 'runtime-bridge', + requiredProfiles: ['fast', 'comms'], + docs: { required: false }, + }); + expect(example.data.requiredTests).toEqual([ + 'tests/unit/session-catalog.test.ts', + 'tests/unit/chat-session-management.test.ts', + ]); + expect(example.body).toContain('session catalog alongside ACP Chat'); + expect(example.body).not.toMatch(/chat\.history|sendMessage|activeRunId|pendingFinal/); + }); + it('parses Markdown frontmatter with arrays and nested docs', () => { const spec = parseFrontmatter(`--- id: example @@ -274,7 +346,7 @@ Body`); }); it('matches repository glob paths', () => { - expect(pathMatchesAny('src/stores/chat/history-actions.ts', ['src/stores/chat/**'])).toBe(true); + expect(pathMatchesAny('src/stores/chat/runtime-graph.ts', ['src/stores/chat/**'])).toBe(true); expect(pathMatchesAny('src/lib/host-api.ts', ['src/lib/host-api.ts'])).toBe(true); expect(pathMatchesAny('src/pages/Chat/index.tsx', ['electron/gateway/**'])).toBe(false); }); diff --git a/tests/unit/history-rpc-params.test.ts b/tests/unit/history-rpc-params.test.ts deleted file mode 100644 index 55cc1fba..00000000 --- a/tests/unit/history-rpc-params.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - buildChatHistoryRpcParams, - DEFAULT_CHAT_HISTORY_MAX_CHARS, - getChatHistoryMaxChars, - OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP, - resetChatHistoryMaxCharsCache, - resolveChatHistoryMaxChars, -} from '@/stores/chat/history-rpc-params'; - -describe('history-rpc-params', () => { - it('builds chat.history params with maxChars', () => { - expect(buildChatHistoryRpcParams('agent:main:main', 200, 120_000)).toEqual({ - sessionKey: 'agent:main:main', - limit: 200, - maxChars: 120_000, - }); - }); - - it('defaults to the OpenClaw cap when config is unavailable', async () => { - resetChatHistoryMaxCharsCache(); - await expect(resolveChatHistoryMaxChars()).resolves.toBe(DEFAULT_CHAT_HISTORY_MAX_CHARS); - expect(DEFAULT_CHAT_HISTORY_MAX_CHARS).toBe(OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP); - }); - - it('reads gateway.webchat.chatHistoryMaxChars from config.get', async () => { - resetChatHistoryMaxCharsCache(); - const rpc = vi.fn(async (method: string) => { - if (method === 'config.get') { - return { config: { gateway: { webchat: { chatHistoryMaxChars: 250_000 } } } }; - } - throw new Error(`Unexpected RPC: ${method}`); - }); - await expect(resolveChatHistoryMaxChars(rpc)).resolves.toBe(DEFAULT_CHAT_HISTORY_MAX_CHARS); - await vi.waitFor(async () => { - await expect(resolveChatHistoryMaxChars()).resolves.toBe(250_000); - }); - }); - - it('clamps configured maxChars to the OpenClaw cap', async () => { - resetChatHistoryMaxCharsCache(); - const rpc = vi.fn(async (method: string) => { - if (method === 'config.get') { - return { config: { gateway: { webchat: { chatHistoryMaxChars: 900_000 } } } }; - } - throw new Error(`Unexpected RPC: ${method}`); - }); - await expect(resolveChatHistoryMaxChars(rpc)).resolves.toBe(DEFAULT_CHAT_HISTORY_MAX_CHARS); - await vi.waitFor(async () => { - await expect(resolveChatHistoryMaxChars()).resolves.toBe(OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP); - }); - }); - - it('returns the default cap immediately and refreshes config in the background', async () => { - resetChatHistoryMaxCharsCache(); - const rpc = vi.fn(async (method: string) => { - if (method === 'config.get') { - return { config: { gateway: { webchat: { chatHistoryMaxChars: 180_000 } } } }; - } - throw new Error(`Unexpected RPC: ${method}`); - }); - - expect(getChatHistoryMaxChars(rpc)).toBe(500_000); - await vi.waitFor(() => { - expect(getChatHistoryMaxChars()).toBe(180_000); - }); - }); -}); diff --git a/tests/unit/history-startup-retry.test.ts b/tests/unit/history-startup-retry.test.ts deleted file mode 100644 index ec100504..00000000 --- a/tests/unit/history-startup-retry.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { classifyHistoryStartupRetryError } from '@/stores/chat/history-startup-retry'; - -describe('history startup retry classification', () => { - it('treats dotted and colon chat history timeout names as retryable timeouts', () => { - expect(classifyHistoryStartupRetryError(new Error('RPC timeout: chat.history'))).toBe('timeout'); - expect(classifyHistoryStartupRetryError(new Error('RPC timeout: chat:history'))).toBe('timeout'); - expect(classifyHistoryStartupRetryError(new Error('Gateway RPC timeout: chat:history'))).toBe('timeout'); - expect(classifyHistoryStartupRetryError(new Error('Gateway WS timeout: chat:history'))).toBe('timeout'); - }); -}); diff --git a/tests/unit/history-transcript-fa3ccd85-fixture.test.ts b/tests/unit/history-transcript-fa3ccd85-fixture.test.ts deleted file mode 100644 index 1535326d..00000000 --- a/tests/unit/history-transcript-fa3ccd85-fixture.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { hydrateGatewayHistoryFromTranscript } from '@/stores/chat/history-transcript-hydrate'; -import { - gatewayHistoryNeedsTranscriptHydration, - mergeGatewayHistoryWithTranscript, -} from '@/stores/chat/history-transcript-merge'; -import type { RawMessage } from '@/stores/chat/types'; - -const { hostApiFetchMock } = vi.hoisted(() => ({ - hostApiFetchMock: vi.fn(), -})); - -vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), - hostApi: { - sessions: { - history: (input: { sessionKey?: string; limit?: number }) => { - const params = new URLSearchParams(); - if (input.sessionKey) params.set('sessionKey', input.sessionKey); - params.set('limit', String(input.limit ?? 200)); - return hostApiFetchMock(`/api/sessions/transcript?${params.toString()}`); - }, - }, - }, -})); - -const SESSION_KEY = 'agent:main:session-long-reply'; -const OPENCLAW_DEFAULT_HISTORY_TEXT_MAX_CHARS = 8_000; -const LONG_REPLY_HEAD = 'Cooper,我先给一个直接判断:**你们现在最有价值的不是“AI Agent 客户端”,而是“企业无人办公转型的落地系统”**。'; -const LONG_REPLY_TAIL = '这句话我觉得挺稳,也适合放进商业计划书。'; -const LONG_REPLY_LENGTH = 8360; - -function buildLongAssistantText(): string { - const fillerLength = Math.max(0, LONG_REPLY_LENGTH - LONG_REPLY_HEAD.length - LONG_REPLY_TAIL.length); - return `${LONG_REPLY_HEAD}${'x'.repeat(fillerLength)}${LONG_REPLY_TAIL}`; -} - -function buildLongAssistantMessage(): RawMessage { - return { - id: 'assistant-long-reply', - role: 'assistant', - content: [ - { type: 'thinking', thinking: '' }, - { type: 'text', text: buildLongAssistantText() }, - ], - timestamp: 1779695766656, - }; -} - -function extractText(content: unknown): string { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - return (content as Array<{ type?: string; text?: string }>) - .filter((block) => block.type === 'text' && typeof block.text === 'string') - .map((block) => block.text!) - .join('\n'); -} - -function simulateGatewayHistoryTruncation(text: string, maxChars = OPENCLAW_DEFAULT_HISTORY_TEXT_MAX_CHARS): string { - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}\n...(truncated)...`; -} - -function simulateGatewayHistoryTruncationContent( - content: unknown, - maxChars = OPENCLAW_DEFAULT_HISTORY_TEXT_MAX_CHARS, -): unknown { - if (typeof content === 'string') { - return simulateGatewayHistoryTruncation(content, maxChars); - } - if (!Array.isArray(content)) return content; - return (content as Array<{ type?: string; text?: string }>).map((block) => { - if (block.type !== 'text' || typeof block.text !== 'string') return block; - return { - ...block, - text: simulateGatewayHistoryTruncation(block.text, maxChars), - }; - }); -} - -describe('long assistant transcript hydration regression', () => { - it('restores a long assistant reply truncated by the default chat.history limit', () => { - const assistant = buildLongAssistantMessage(); - const fullText = extractText(assistant.content); - - expect(fullText.length).toBe(LONG_REPLY_LENGTH); - expect(fullText.length).toBeGreaterThan(OPENCLAW_DEFAULT_HISTORY_TEXT_MAX_CHARS); - expect(fullText).toContain('企业无人办公转型的落地系统'); - expect(fullText).toContain(LONG_REPLY_TAIL); - - const transcriptMessages: RawMessage[] = [assistant]; - const gatewayMessages: RawMessage[] = [{ - ...assistant, - content: simulateGatewayHistoryTruncationContent(assistant.content), - }]; - - expect(gatewayHistoryNeedsTranscriptHydration(gatewayMessages)).toBe(true); - - const merged = mergeGatewayHistoryWithTranscript(gatewayMessages, transcriptMessages); - const mergedText = extractText(merged[0]?.content); - - expect(mergedText).toBe(fullText); - expect(mergedText).not.toContain('...(truncated)...'); - expect(mergedText.length).toBe(LONG_REPLY_LENGTH); - }); - - it('hydrates truncated gateway history through the transcript fallback path', async () => { - const assistant = buildLongAssistantMessage(); - const fullText = extractText(assistant.content); - const transcriptMessages: RawMessage[] = [assistant]; - - hostApiFetchMock.mockResolvedValueOnce({ messages: transcriptMessages }); - - const gatewayMessages: RawMessage[] = [{ - ...assistant, - content: simulateGatewayHistoryTruncationContent(assistant.content), - }]; - - const hydrated = await hydrateGatewayHistoryFromTranscript( - SESSION_KEY, - gatewayMessages, - 200, - ); - - expect(hostApiFetchMock).toHaveBeenCalledWith( - `/api/sessions/transcript?sessionKey=${encodeURIComponent(SESSION_KEY)}&limit=200`, - ); - expect(extractText(hydrated[0]?.content)).toBe(fullText); - expect(gatewayHistoryNeedsTranscriptHydration(hydrated)).toBe(false); - }); -}); diff --git a/tests/unit/history-transcript-merge.test.ts b/tests/unit/history-transcript-merge.test.ts deleted file mode 100644 index 7427bfe7..00000000 --- a/tests/unit/history-transcript-merge.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - gatewayHistoryNeedsTranscriptHydration, - isTruncatedHistoryText, - mergeGatewayHistoryWithTranscript, -} from '@/stores/chat/history-transcript-merge'; -import type { RawMessage } from '@/stores/chat/types'; - -describe('history-transcript-merge', () => { - it('detects OpenClaw truncation markers', () => { - expect(isTruncatedHistoryText('hello\n...(truncated)...')).toBe(true); - expect(isTruncatedHistoryText('hello\n…(truncated)…')).toBe(true); - expect(isTruncatedHistoryText('[chat.history omitted: message too large]')).toBe(true); - expect(isTruncatedHistoryText('complete response')).toBe(false); - }); - - it('merges full transcript text over truncated gateway history', () => { - const gatewayMessages: RawMessage[] = [{ - id: 'm1', - role: 'assistant', - content: `${'a'.repeat(100)}\n...(truncated)...`, - timestamp: 1000, - }]; - const transcriptMessages: RawMessage[] = [{ - id: 'm1', - role: 'assistant', - content: 'a'.repeat(500), - timestamp: 1000, - }]; - - expect(gatewayHistoryNeedsTranscriptHydration(gatewayMessages)).toBe(true); - const merged = mergeGatewayHistoryWithTranscript(gatewayMessages, transcriptMessages); - expect(merged[0]?.content).toBe('a'.repeat(500)); - expect(gatewayHistoryNeedsTranscriptHydration(merged)).toBe(false); - }); - - it('leaves non-truncated gateway messages unchanged', () => { - const gatewayMessages: RawMessage[] = [{ - role: 'assistant', - content: 'short reply', - timestamp: 1000, - }]; - const transcriptMessages: RawMessage[] = [{ - role: 'assistant', - content: 'different reply', - timestamp: 1000, - }]; - - expect(mergeGatewayHistoryWithTranscript(gatewayMessages, transcriptMessages)).toEqual(gatewayMessages); - }); -}); diff --git a/tests/unit/host-api-facade.test.ts b/tests/unit/host-api-facade.test.ts index a69cac70..6f3d675d 100644 --- a/tests/unit/host-api-facade.test.ts +++ b/tests/unit/host-api-facade.test.ts @@ -418,17 +418,6 @@ describe('hostApi facade', () => { })); }); - it('calls chat.sendWithMedia through hostInvoke', async () => { - hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true } }); - const { hostApi } = await import('@/lib/host-api'); - - await hostApi.chat.sendWithMedia({ sessionKey: 'main', message: 'hello', idempotencyKey: 'k' }); - expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ - module: 'chat', - action: 'sendWithMedia', - })); - }); - it('routes ACP chat methods through hostInvoke', async () => { hostInvoke .mockResolvedValueOnce({ id: 'req-1', ok: true, data: { success: true, generation: 1 } }) @@ -437,6 +426,13 @@ describe('hostApi facade', () => { .mockResolvedValueOnce({ id: 'req-4', ok: true, data: { success: true } }); const { hostApi } = await import('@/lib/host-api'); + expect(Object.keys(hostApi.chat)).toEqual([ + 'loadAcpSession', + 'sendAcpPrompt', + 'cancelAcpSession', + 'respondAcpPermission', + ]); + await hostApi.chat.loadAcpSession({ sessionKey: 'main', workspaceRoot: '/workspace', diff --git a/tests/unit/host-services.test.ts b/tests/unit/host-services.test.ts index 1387df6c..7d6e5723 100644 --- a/tests/unit/host-services.test.ts +++ b/tests/unit/host-services.test.ts @@ -13,6 +13,9 @@ const { deleteChannelAccountConfigMock, deleteChannelConfigMock, ensureFeishuPluginInstalledMock, + ensureScopedChannelBindingMock, + ensureClawXContextMock, + ensureWeChatPluginInstalledMock, getAllSettingsMock, getChannelFormValuesMock, getSettingMock, @@ -23,6 +26,7 @@ const { listConfiguredChannelAccountsFromConfigMock, listConfiguredChannelsFromConfigMock, listConfiguredChannelsMock, + migrateLegacyChannelWideBindingMock, providerAccountToConfigMock, providerServiceMock, readOpenClawConfigMock, @@ -30,6 +34,8 @@ const { removeAgentWorkspaceDirectoryMock, resetSettingsMock, saveChannelConfigMock, + setChannelDefaultAccountMock, + setChannelEnabledMock, setSettingMock, syncDefaultProviderToRuntimeMock, syncDeletedProviderToRuntimeMock, @@ -39,6 +45,9 @@ const { testOpenClawConfigDir, updateAgentNameMock, validateApiKeyWithProviderMock, + saveWeChatAccountStateMock, + startWeChatLoginSessionMock, + waitForWeChatLoginSessionMock, } = vi.hoisted(() => ({ applyProxySettingsMock: vi.fn(), assignChannelAccountToAgentMock: vi.fn(), @@ -49,6 +58,9 @@ const { deleteChannelAccountConfigMock: vi.fn(), deleteChannelConfigMock: vi.fn(), ensureFeishuPluginInstalledMock: vi.fn(), + ensureScopedChannelBindingMock: vi.fn(), + ensureClawXContextMock: vi.fn(), + ensureWeChatPluginInstalledMock: vi.fn(), getAllSettingsMock: vi.fn(), getChannelFormValuesMock: vi.fn(), getSettingMock: vi.fn(), @@ -59,6 +71,7 @@ const { listConfiguredChannelAccountsFromConfigMock: vi.fn(), listConfiguredChannelsFromConfigMock: vi.fn(), listConfiguredChannelsMock: vi.fn(), + migrateLegacyChannelWideBindingMock: vi.fn(), providerAccountToConfigMock: vi.fn((account: Record) => ({ id: account.id, name: account.label, @@ -98,6 +111,8 @@ const { removeAgentWorkspaceDirectoryMock: vi.fn(), resetSettingsMock: vi.fn(), saveChannelConfigMock: vi.fn(), + setChannelDefaultAccountMock: vi.fn(), + setChannelEnabledMock: vi.fn(), setSettingMock: vi.fn(), syncDefaultProviderToRuntimeMock: vi.fn(), syncDeletedProviderToRuntimeMock: vi.fn(), @@ -107,6 +122,9 @@ const { testOpenClawConfigDir: '/tmp/clawx-host-services-openclaw', updateAgentNameMock: vi.fn(), validateApiKeyWithProviderMock: vi.fn(), + saveWeChatAccountStateMock: vi.fn(), + startWeChatLoginSessionMock: vi.fn(), + waitForWeChatLoginSessionMock: vi.fn(), })); vi.mock('@electron/utils/store', () => ({ @@ -154,8 +172,8 @@ vi.mock('@electron/utils/channel-config', () => ({ listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsFromConfigMock(...args), readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args), saveChannelConfig: (...args: unknown[]) => saveChannelConfigMock(...args), - setChannelDefaultAccount: vi.fn(), - setChannelEnabled: vi.fn(), + setChannelDefaultAccount: (...args: unknown[]) => setChannelDefaultAccountMock(...args), + setChannelEnabled: (...args: unknown[]) => setChannelEnabledMock(...args), validateChannelConfig: vi.fn(), validateChannelCredentials: vi.fn(), })); @@ -167,8 +185,10 @@ vi.mock('@electron/utils/agent-config', () => ({ clearChannelBinding: (...args: unknown[]) => clearChannelBindingMock(...args), createAgent: (...args: unknown[]) => createAgentMock(...args), deleteAgentConfig: (...args: unknown[]) => deleteAgentConfigMock(...args), + ensureScopedChannelBinding: (...args: unknown[]) => ensureScopedChannelBindingMock(...args), listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args), listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotFromConfigMock(...args), + migrateLegacyChannelWideBinding: (...args: unknown[]) => migrateLegacyChannelWideBindingMock(...args), removeAgentWorkspaceDirectory: (...args: unknown[]) => removeAgentWorkspaceDirectoryMock(...args), resolveAccountIdForAgent: vi.fn((agentId: string) => agentId === 'main' ? 'default' : agentId), updateAgentModel: vi.fn(), @@ -180,13 +200,13 @@ vi.mock('@electron/utils/plugin-install', () => ({ ensureDingTalkPluginInstalled: vi.fn(), ensureFeishuPluginInstalled: (...args: unknown[]) => ensureFeishuPluginInstalledMock(...args), ensureQQBotPluginInstalled: vi.fn(), - ensureWeChatPluginInstalled: vi.fn(), + ensureWeChatPluginInstalled: (...args: unknown[]) => ensureWeChatPluginInstalledMock(...args), ensureWeComPluginInstalled: vi.fn(), ensureWhatsAppPluginInstalled: vi.fn(), })); vi.mock('@electron/utils/openclaw-workspace', () => ({ - ensureClawXContext: vi.fn(), + ensureClawXContext: (...args: unknown[]) => ensureClawXContextMock(...args), })); vi.mock('@electron/services/providers/provider-runtime-sync', () => ({ @@ -237,9 +257,9 @@ vi.mock('@electron/utils/device-oauth', () => ({ vi.mock('@electron/utils/wechat-login', () => ({ cancelWeChatLoginSession: vi.fn(), - saveWeChatAccountState: vi.fn(), - startWeChatLoginSession: vi.fn(), - waitForWeChatLoginSession: vi.fn(), + saveWeChatAccountState: (...args: unknown[]) => saveWeChatAccountStateMock(...args), + startWeChatLoginSession: (...args: unknown[]) => startWeChatLoginSessionMock(...args), + waitForWeChatLoginSession: (...args: unknown[]) => waitForWeChatLoginSessionMock(...args), })); vi.mock('@electron/utils/whatsapp-login', () => ({ @@ -323,6 +343,8 @@ describe('host services', () => { providerServiceMock.setDefaultAccount.mockResolvedValue(undefined); validateApiKeyWithProviderMock.mockResolvedValue({ valid: true }); ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true }); + ensureWeChatPluginInstalledMock.mockResolvedValue({ installed: true }); + ensureClawXContextMock.mockResolvedValue(undefined); rmSync(logDir, { recursive: true, force: true }); rmSync(testOpenClawConfigDir, { recursive: true, force: true }); mkdirSync(logDir, { recursive: true }); @@ -382,28 +404,29 @@ describe('host services', () => { expect(setSettingMock).toHaveBeenCalledWith('recentWorkspacePaths', ['/Users/alex/workspace/ClawX']); }); - it('routes gateway rpc through backpressure', async () => { + it('routes validated generic gateway rpc directly to the manager', async () => { const gatewayManager = { rpc: vi.fn(async () => ({ ok: true })), }; - const backpressure = { - run: vi.fn(async (_method, _params, _timeoutMs, runner) => runner('chat.history', { limit: 1 }, 42)), - }; const { createGatewayApi } = await import('@electron/services/gateway-api'); + const gatewayApi = createGatewayApi(gatewayManager as never); - await expect(createGatewayApi(gatewayManager as never, backpressure as never).rpc({ - method: 'chat.history', - params: { limit: 1 }, + await expect(gatewayApi.rpc({ + method: ' sessions.list ', + params: { includeDerivedTitles: true }, timeoutMs: 42, })).resolves.toEqual({ ok: true }); - expect(backpressure.run).toHaveBeenCalledWith( - 'chat.history', - { limit: 1 }, + expect(gatewayManager.rpc).toHaveBeenCalledWith( + 'sessions.list', + { includeDerivedTitles: true }, 42, - expect.any(Function), ); - expect(gatewayManager.rpc).toHaveBeenCalledWith('chat.history', { limit: 1 }, 42); + await expect(gatewayApi.rpc({ method: ' ' })).rejects.toThrow('Invalid gateway RPC method'); + await expect(gatewayApi.rpc({ method: 'status', timeoutMs: 0 })).rejects.toThrow( + 'Invalid gateway RPC timeout', + ); + expect(gatewayManager.rpc).toHaveBeenCalledTimes(1); }); it('exposes provider account snapshot actions through the typed providers service', async () => { @@ -499,6 +522,28 @@ describe('host services', () => { ); }); + it('removes provider runtime state before deleting the local provider record', async () => { + const provider = { + id: 'custom-local', + name: 'Local', + type: 'custom', + baseUrl: 'http://127.0.0.1:1234/v1', + enabled: true, + }; + providerServiceMock._getProviderInternal.mockResolvedValue(provider); + const gatewayManager = {}; + const { createProvidersApi } = await import('@electron/services/providers-api'); + + await expect(createProvidersApi({ + gatewayManager: gatewayManager as never, + mainWindow: {} as never, + }).delete({ providerId: provider.id })).resolves.toEqual({ success: true }); + + expect(syncDeletedProviderToRuntimeMock).toHaveBeenCalledWith(provider, provider.id, gatewayManager); + expect(syncDeletedProviderToRuntimeMock.mock.invocationCallOrder[0]) + .toBeLessThan(providerServiceMock._deleteProviderInternal.mock.invocationCallOrder[0]); + }); + it('sets the default provider account and syncs runtime defaults', async () => { providerServiceMock.getDefaultAccountId.mockResolvedValue('old-default'); const gatewayManager = { debouncedReload: vi.fn() }; @@ -722,7 +767,7 @@ describe('host services', () => { await expect(channelsApi.targets({ accountId: 'ding-main' })).rejects.toThrow('channelType is required'); }); - it('saves channel binding for existing agents and schedules channel refresh', async () => { + it('saves channel binding for existing agents without scheduling lifecycle work', async () => { listAgentsSnapshotMock.mockResolvedValue({ agents: [{ id: 'main', name: 'Main' }], defaultAgentId: 'main', @@ -745,11 +790,37 @@ describe('host services', () => { })).resolves.toEqual({ success: true }); expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'default'); - expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(150); + expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); }); - it('installs plugin, saves config, ensures scoped binding, and schedules refresh on saveConfig', async () => { + it('requests legacy migration inside the scoped binding transaction', async () => { + listAgentsSnapshotMock.mockResolvedValue({ + agents: [{ id: 'research', name: 'Research' }], + defaultAgentId: 'research', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: {}, + channelAccountOwners: {}, + }); + const { createChannelsApi } = await import('@electron/services/channels-api'); + + await expect(createChannelsApi({ gatewayManager: {} as never }).bindingSave({ + channelType: 'feishu', + accountId: 'research', + agentId: 'research', + })).resolves.toEqual({ success: true }); + + expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith( + 'research', + 'feishu', + 'research', + { migrateLegacy: true }, + ); + expect(migrateLegacyChannelWideBindingMock).not.toHaveBeenCalled(); + }); + + it('installs plugin, saves config, and ensures scoped binding without scheduling lifecycle work', async () => { listAgentsSnapshotMock.mockResolvedValue({ agents: [{ id: 'main', name: 'Main' }], defaultAgentId: 'main', @@ -778,11 +849,12 @@ describe('host services', () => { { appId: 'cli_new', appSecret: 'new-secret' }, 'default', ); - expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'default'); - expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(150); + expect(ensureScopedChannelBindingMock).toHaveBeenCalledWith('feishu', 'default'); + expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); }); - it('deletes agents by restarting gateway, removing workspace, and returning snapshot', async () => { + it('deletes agents by awaiting config commit then removing workspace without restarting', async () => { const snapshot = { agents: [], defaultAgentId: 'main', @@ -795,7 +867,7 @@ describe('host services', () => { deleteAgentConfigMock.mockResolvedValue({ snapshot, removedEntry }); removeAgentWorkspaceDirectoryMock.mockResolvedValue(undefined); const gatewayManager = { - getStatus: vi.fn(() => ({ state: 'running' })), + getStatus: vi.fn(() => ({ state: 'stopped' })), restart: vi.fn().mockResolvedValue(undefined), }; const { createAgentsApi } = await import('@electron/services/agents-api'); @@ -804,11 +876,13 @@ describe('host services', () => { .resolves.toEqual({ success: true, ...snapshot }); expect(deleteAgentConfigMock).toHaveBeenCalledWith('code'); - expect(gatewayManager.restart).toHaveBeenCalledTimes(1); + expect(gatewayManager.restart).not.toHaveBeenCalled(); expect(removeAgentWorkspaceDirectoryMock).toHaveBeenCalledWith(removedEntry); + expect(deleteAgentConfigMock.mock.invocationCallOrder[0]) + .toBeLessThan(removeAgentWorkspaceDirectoryMock.mock.invocationCallOrder[0]); }); - it('updates agent model and schedules gateway reload', async () => { + it('updates agent model without scheduling lifecycle work', async () => { const snapshot = { agents: [{ id: 'main', modelRef: 'custom-enterpri/claude-sonnet-4' }], defaultAgentId: 'main', @@ -836,10 +910,10 @@ describe('host services', () => { expect(agentConfig.updateAgentModel).toHaveBeenCalledWith('main', 'custom-enterpri/claude-sonnet-4'); expect(providerRuntimeSync.syncAllProviderAuthToRuntime).toHaveBeenCalledTimes(1); expect(providerRuntimeSync.syncAgentModelOverrideToRuntime).toHaveBeenCalledWith('main'); - expect(gatewayManager.debouncedReload).toHaveBeenCalledTimes(1); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); }); - it('assigns agent channels and schedules gateway reload', async () => { + it('assigns agent channels without scheduling lifecycle work', async () => { const snapshot = { agents: [{ id: 'main', channelTypes: ['feishu'] }], defaultAgentId: 'main', @@ -861,7 +935,160 @@ describe('host services', () => { })).resolves.toEqual({ success: true, ...snapshot }); expect(assignChannelToAgentMock).toHaveBeenCalledWith('main', 'feishu'); - expect(gatewayManager.debouncedReload).toHaveBeenCalledTimes(1); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); + }); + + it('creates and updates agents without scheduling lifecycle work', async () => { + const snapshot = { + agents: [{ id: 'writer', name: 'Writer' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: [], + channelOwners: {}, + channelAccountOwners: {}, + }; + createAgentMock.mockResolvedValue(snapshot); + updateAgentNameMock.mockResolvedValue(snapshot); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running' })), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + restart: vi.fn(), + }; + const { createAgentsApi } = await import('@electron/services/agents-api'); + const agentsApi = createAgentsApi({ gatewayManager: gatewayManager as never }); + + await expect(agentsApi.create({ name: 'Writer' })).resolves.toEqual({ success: true, ...snapshot }); + await expect(agentsApi.update({ id: 'writer', name: 'Writer' })).resolves.toEqual({ success: true, ...snapshot }); + + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); + expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); + expect(gatewayManager.restart).not.toHaveBeenCalled(); + }); + + it('removes agent channel bindings without scheduling lifecycle work', async () => { + listAgentsSnapshotMock + .mockResolvedValueOnce({ + agents: [{ id: 'writer' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: { feishu: 'writer' }, + channelAccountOwners: { 'feishu:writer': 'writer' }, + }) + .mockResolvedValueOnce({ + agents: [{ id: 'writer' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: [], + channelOwners: {}, + channelAccountOwners: {}, + }); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running' })), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + restart: vi.fn(), + }; + const { createAgentsApi } = await import('@electron/services/agents-api'); + + await createAgentsApi({ gatewayManager: gatewayManager as never }).removeChannel({ + id: 'writer', + channelType: 'feishu', + }); + + expect(deleteChannelAccountConfigMock).toHaveBeenCalledWith('feishu', 'writer'); + expect(clearChannelBindingMock).toHaveBeenCalledWith('feishu', 'writer'); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); + expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); + expect(gatewayManager.restart).not.toHaveBeenCalled(); + }); + + it('handles channel default, binding delete, enable, delete, login, and no-change without lifecycle work', async () => { + getChannelFormValuesMock.mockResolvedValue({ appId: 'same', appSecret: 'same-secret' }); + listAgentsSnapshotMock.mockResolvedValue({ + agents: [{ id: 'main', name: 'Main' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: {}, + channelAccountOwners: {}, + }); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running' })), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + restart: vi.fn(), + }; + const { createChannelsApi } = await import('@electron/services/channels-api'); + const channelsApi = createChannelsApi({ gatewayManager: gatewayManager as never }); + + await channelsApi.setDefaultAccount({ channelType: 'feishu', accountId: 'default' }); + await channelsApi.bindingDelete({ channelType: 'feishu', accountId: 'default' }); + await channelsApi.setEnabled({ channelType: 'feishu', enabled: true }); + await channelsApi.deleteConfig({ channelType: 'feishu', accountId: 'default' }); + await channelsApi.deleteConfig({ channelType: 'feishu' }); + await channelsApi.startLogin({ channelType: 'whatsapp', accountId: 'default' }); + await expect(channelsApi.saveConfig({ + channelType: 'feishu', + accountId: 'default', + config: { appId: 'same', appSecret: 'same-secret' }, + })).resolves.toEqual({ success: true, noChange: true }); + + expect(setChannelDefaultAccountMock).toHaveBeenCalledWith('feishu', 'default'); + expect(clearChannelBindingMock).toHaveBeenCalledWith('feishu', 'default'); + expect(setChannelEnabledMock).toHaveBeenCalledWith('feishu', true); + expect(deleteChannelAccountConfigMock).toHaveBeenCalledWith('feishu', 'default'); + expect(deleteChannelConfigMock).toHaveBeenCalledWith('feishu'); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); + expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); + expect(gatewayManager.restart).not.toHaveBeenCalled(); + }); + + it('does not register OAuth success restart listeners', () => { + const source = readFileSync(join(process.cwd(), 'electron/main/ipc-handlers.ts'), 'utf8'); + + expect(source).not.toMatch(/\.on\(['"]oauth:success['"]/); + expect(source).not.toContain('debouncedRestart(8000)'); + }); + + it('persists successful WeChat login without scheduling lifecycle work', async () => { + startWeChatLoginSessionMock.mockResolvedValue({ + qrcodeUrl: 'https://example.com/qr', + sessionKey: 'session-1', + }); + waitForWeChatLoginSessionMock.mockResolvedValue({ + connected: true, + accountId: 'wx-account', + botToken: 'wx-token', + baseUrl: 'https://api.example.com', + userId: 'wx-user', + }); + saveWeChatAccountStateMock.mockResolvedValue('wx-account'); + listAgentsSnapshotMock.mockResolvedValue({ + agents: [{ id: 'main', name: 'Main' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['openclaw-weixin'], + channelOwners: {}, + channelAccountOwners: {}, + }); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running' })), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + restart: vi.fn(), + }; + const { createChannelsApi } = await import('@electron/services/channels-api'); + + await createChannelsApi({ gatewayManager: gatewayManager as never }).startLogin({ channelType: 'wechat' }); + + await vi.waitFor(() => { + expect(saveChannelConfigMock).toHaveBeenCalledWith('wechat', { enabled: true }, 'wx-account'); + }); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); + expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); + expect(gatewayManager.restart).not.toHaveBeenCalled(); }); it('returns diagnostics snapshot with channel view and log tails', async () => { @@ -979,39 +1206,19 @@ describe('host services', () => { ); }); - it('sends staged media through the typed chat service with gateway attachments', async () => { - const mediaPath = join(tmpdir(), `clawx-host-services-media-${Date.now()}.png`); - writeFileSync(mediaPath, 'fake-image-bytes'); - const gatewayManager = { - rpc: vi.fn().mockResolvedValue({ runId: 'run-123' }), - }; + it('registers exactly the four ACP chat actions', async () => { const { createChatApi } = await import('@electron/services/chat-api'); - await expect(createChatApi({ - gatewayManager: gatewayManager as never, + expect(Object.keys(createChatApi({ + gatewayManager: {} as never, mainWindow: {} as never, - }).sendWithMedia({ - sessionKey: 'agent:main:main', - message: 'inspect this', - idempotencyKey: 'idem-123', - media: [{ filePath: mediaPath, mimeType: 'image/png', fileName: 'image.png' }], - })).resolves.toEqual({ success: true, result: { runId: 'run-123' } }); - - expect(gatewayManager.rpc).toHaveBeenCalledWith( - 'chat.send', - { - sessionKey: 'agent:main:main', - message: `inspect this\n\n[media attached: ${mediaPath} (image/png) | ${mediaPath}]`, - deliver: false, - idempotencyKey: 'idem-123', - attachments: [{ - content: Buffer.from('fake-image-bytes').toString('base64'), - mimeType: 'image/png', - fileName: 'image.png', - }], - }, - 120000, - ); + acpSessionAccessRegistry: {} as never, + }))).toEqual([ + 'loadAcpSession', + 'sendAcpPrompt', + 'cancelAcpSession', + 'respondAcpPermission', + ]); }); it('loads session summaries and transcript history through the typed sessions service', async () => { diff --git a/tests/unit/image-generation-status.test.ts b/tests/unit/image-generation-status.test.ts deleted file mode 100644 index c80a09a0..00000000 --- a/tests/unit/image-generation-status.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { RawMessage } from '@/stores/chat'; -import { - hasDeliveredImageGenerationResult, - IMAGE_GENERATION_TIMEOUT_MS, - isImageGenerationPending, -} from '@/pages/Chat/image-generation-status'; - -const TASK_ID = '27443fdb-6cca-48e6-a3a7-ee34b0491aee'; -const NOW = 1_700_000_000_000; - -describe('isImageGenerationPending', () => { - it('returns true while waiting after image_generate without a final reply', () => { - const segmentMessages: RawMessage[] = [ - { - role: 'assistant', - timestamp: NOW / 1000, - content: [{ type: 'toolCall', id: 'call_1', name: 'image_generate', arguments: { prompt: 'astronaut' } }], - }, - { - role: 'assistant', - timestamp: NOW / 1000 + 5, - content: [{ type: 'text', text: '图片生成中,稍等片刻 🧑‍🚀✨' }], - }, - ]; - - expect(isImageGenerationPending(segmentMessages, [], NOW)).toBe(true); - }); - - it('stays pending after async task start even if the agent posts a visible status reply', () => { - const segmentMessages: RawMessage[] = [ - { - role: 'assistant', - timestamp: NOW / 1000, - content: [{ type: 'toolCall', id: 'call_1', name: 'image_generate', arguments: { prompt: 'cherry' } }], - }, - { - role: 'toolresult', - toolName: 'image_generate', - timestamp: NOW / 1000 + 1, - content: [{ - type: 'text', - text: `Background task started for image generation (${TASK_ID}).`, - }], - }, - { - role: 'assistant', - timestamp: NOW / 1000 + 2, - content: [{ type: 'text', text: '图已经开始生成啦 🍒 完成后会直接发到这里。' }], - }, - ]; - - expect(isImageGenerationPending(segmentMessages, [], NOW + 2_000)).toBe(true); - }); - - it('returns false after the inter-session completion event arrives', () => { - const segmentMessages: RawMessage[] = [ - { - role: 'toolresult', - toolName: 'image_generate', - timestamp: NOW / 1000, - content: [{ - type: 'text', - text: `Background task started for image generation (${TASK_ID}).`, - }], - }, - { - role: 'user', - content: [{ - type: 'text', - text: `[Inter-session message] sourceSession=image_generate:${TASK_ID} sourceTool=image_generate`, - }], - }, - ]; - - expect(isImageGenerationPending(segmentMessages, [], NOW)).toBe(false); - }); - - it('returns false once a non-generating assistant reply arrives', () => { - const segmentMessages: RawMessage[] = [ - { - role: 'assistant', - timestamp: NOW / 1000, - content: [{ type: 'toolCall', id: 'call_1', name: 'image_generate', arguments: { prompt: 'astronaut' } }], - }, - { - role: 'assistant', - timestamp: NOW / 1000 + 5, - content: [{ type: 'text', text: '图片生成中,稍等片刻 🧑‍🚀✨' }], - }, - { - role: 'assistant', - timestamp: NOW / 1000 + 120, - content: [{ type: 'text', text: '抱歉,图片生成超时了,稍后重试一下吧 🧑‍🚀' }], - }, - ]; - - expect(isImageGenerationPending(segmentMessages, [], NOW + 120_000)).toBe(false); - }); - - it('returns false after the configured timeout even if only generating narration exists', () => { - const segmentMessages: RawMessage[] = [ - { - role: 'assistant', - timestamp: NOW / 1000, - content: [{ type: 'toolCall', id: 'call_1', name: 'image_generate', arguments: { prompt: 'astronaut' } }], - }, - { - role: 'assistant', - timestamp: NOW / 1000 + 5, - content: [{ type: 'text', text: '图片生成中,稍等片刻 🧑‍🚀✨' }], - }, - ]; - - expect( - isImageGenerationPending( - segmentMessages, - [], - NOW + IMAGE_GENERATION_TIMEOUT_MS + 20_000, - ), - ).toBe(false); - }); - - it('returns false when a stale streaming tool status is still marked running', () => { - expect(isImageGenerationPending([], [{ - name: 'image_generate', - status: 'running', - updatedAt: NOW - IMAGE_GENERATION_TIMEOUT_MS - 60_000, - }], NOW)).toBe(false); - }); - - it('returns true while image_generate is actively running in streaming tools', () => { - expect(isImageGenerationPending([], [{ - name: 'image_generate', - status: 'running', - updatedAt: NOW, - }], NOW)).toBe(true); - }); - - it('returns false once an image attachment is delivered', () => { - const segmentMessages: RawMessage[] = [ - { - role: 'assistant', - timestamp: NOW / 1000, - content: [{ type: 'toolCall', id: 'call_1', name: 'image_generate', arguments: { prompt: 'astronaut' } }], - }, - { - role: 'assistant', - timestamp: NOW / 1000 + 90, - content: [{ type: 'text', text: '宇航员图片完成啦 🧑‍🚀✨' }], - _attachedFiles: [{ - fileName: 'astronaut.png', - mimeType: 'image/png', - fileSize: 123, - preview: null, - filePath: '/tmp/astronaut.png', - }], - }, - ]; - - expect(isImageGenerationPending(segmentMessages, [], NOW + 90_000)).toBe(false); - expect(hasDeliveredImageGenerationResult(segmentMessages)).toBe(true); - }); - - it('returns false after message tool delivers generated image media', () => { - const segmentMessages: RawMessage[] = [ - { - role: 'assistant', - timestamp: NOW / 1000, - content: [{ type: 'toolCall', id: 'call_1', name: 'image_generate', arguments: { prompt: 'tomato' } }], - }, - { - role: 'toolresult', - toolName: 'image_generate', - timestamp: NOW / 1000 + 1, - content: [{ - type: 'text', - text: `Background task started for image generation (${TASK_ID}).`, - }], - }, - { - role: 'user', - content: [{ - type: 'text', - text: `[Inter-session message] sourceSession=image_generate:${TASK_ID} sourceTool=image_generate`, - }], - }, - { - role: 'toolresult', - toolName: 'message', - content: [{ type: 'text', text: '{ "status": "ok" }' }], - details: { - status: 'ok', - mediaUrl: '/Users/me/.openclaw/media/tool-image-generation/tomato.png', - sourceReply: { - mediaUrls: ['/Users/me/.openclaw/media/tool-image-generation/tomato.png'], - }, - }, - } as RawMessage, - ]; - - expect(isImageGenerationPending(segmentMessages, [], NOW + 90_000)).toBe(false); - expect(hasDeliveredImageGenerationResult(segmentMessages)).toBe(true); - }); - - it('returns false after image delivery even if streaming tool status is still running', () => { - const segmentMessages: RawMessage[] = [ - { - role: 'assistant', - timestamp: NOW / 1000, - content: [{ type: 'toolCall', id: 'call_1', name: 'image_generate', arguments: { prompt: 'narwhal' } }], - }, - { - role: 'assistant', - timestamp: NOW / 1000 + 90, - content: [{ - type: 'image', - url: '/api/chat/media/outgoing/agent%3Amain%3As-1/narwhal/full', - mimeType: 'image/png', - alt: 'narwhal.png', - }], - _attachedFiles: [{ - fileName: 'narwhal.png', - mimeType: 'image/png', - fileSize: 123, - preview: null, - gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/narwhal/full', - source: 'gateway-media', - }], - }, - ]; - - expect(isImageGenerationPending(segmentMessages, [{ - name: 'image_generate', - status: 'running', - updatedAt: NOW + 90_000, - }], NOW + 90_000)).toBe(false); - }); -}); diff --git a/tests/unit/main-hardware-acceleration.test.ts b/tests/unit/main-hardware-acceleration.test.ts new file mode 100644 index 00000000..8d610050 --- /dev/null +++ b/tests/unit/main-hardware-acceleration.test.ts @@ -0,0 +1,12 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('Electron hardware acceleration policy', () => { + it('does not force every renderer onto software compositing', () => { + const source = readFileSync(join(process.cwd(), 'electron/main/index.ts'), 'utf8'); + + expect(source).not.toMatch(/disableHardwareAcceleration\s*\(/u); + expect(source).not.toMatch(/appendSwitch\s*\(\s*['"]disable-gpu['"]/u); + }); +}); diff --git a/tests/unit/markdown-preview.test.tsx b/tests/unit/markdown-preview.test.tsx new file mode 100644 index 00000000..04610db3 --- /dev/null +++ b/tests/unit/markdown-preview.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import MarkdownPreview from '@/components/file-preview/MarkdownPreview'; + +describe('MarkdownPreview', () => { + it('lets prose margins control vertical spacing without a root margin reset', () => { + const { container } = render(); + const preview = container.querySelector('.clawx-markdown-preview'); + + expect(preview).not.toHaveClass('space-y-0'); + }); + + it.each([ + ['YAML', '---\ntitle: Hidden YAML metadata\n---'], + ['TOML', '+++\ntitle = "Hidden TOML metadata"\n+++'], + ])('omits %s frontmatter without rendering a metadata card', (_format, frontmatter) => { + const { container } = render( + , + ); + + expect(screen.getByRole('heading', { name: 'Visible heading' })).toBeVisible(); + expect(screen.getByText('Visible body')).toBeVisible(); + expect(container).not.toHaveTextContent('Hidden'); + expect(container.querySelector('pre')).not.toBeInTheDocument(); + }); + + it('keeps raw HTML visible without creating active elements', () => { + const source = ''; + const { container } = render(); + + expect(container).toHaveTextContent(source); + expect(container.querySelector('script')).not.toBeInTheDocument(); + }); + + it('renders Markdown links through inert BrowserLink output', () => { + render(); + + expect(screen.getByText('label')).toBeVisible(); + expect(screen.queryByRole('link', { name: 'label' })).not.toBeInTheDocument(); + }); + + it('renders single-dollar math with KaTeX', () => { + const { container } = render(); + + expect(container.querySelector('.katex')).toBeInTheDocument(); + }); + + it('keeps CJK punctuation and following text outside an autolink', () => { + const { container } = render(); + const url = screen.getByText('https://example.com'); + + expect(url).toBeVisible(); + expect(url).toHaveTextContent(/^https:\/\/example\.com$/); + expect(url.parentElement).toHaveTextContent(/^https:\/\/example\.com。后续$/); + expect(container).toHaveTextContent('。后续'); + }); + + it('uses the Streamdown code renderer and asynchronously highlights JavaScript', async () => { + const { container } = render( + , + ); + + const codeBlock = container.querySelector('[data-streamdown="code-block"]'); + expect(codeBlock).toBeInTheDocument(); + await waitFor(() => { + expect(codeBlock?.querySelector('span[style*="--sdm-c"]')).toBeInTheDocument(); + }); + expect(codeBlock?.querySelector('[data-streamdown="code-block-copy-button"]')).toBeVisible(); + expect(codeBlock?.querySelector('[data-streamdown="code-block-download-button"]')).not.toBeInTheDocument(); + }); + + it('keeps Mermaid fences as ordinary code', async () => { + const { container } = render( + B\n```'} />, + ); + + const codeBlock = container.querySelector('[data-streamdown="code-block"]'); + expect(codeBlock).toBeInTheDocument(); + expect(codeBlock).toHaveTextContent('graph TD'); + expect(codeBlock?.querySelector('[data-streamdown="code-block-body"] svg')).not.toBeInTheDocument(); + expect(container.querySelector('[data-streamdown="mermaid"]')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index 3aa6ca3c..be9cb4a9 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -140,6 +140,24 @@ describe('saveProviderKeyToOpenClaw', () => { logSpy.mockRestore(); }); + + it('reloads the running Gateway auth snapshot once after the write batch', async () => { + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string) => { + if (method === 'secrets.reload') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const { saveProviderKeyToOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await saveProviderKeyToOpenClaw('openrouter', 'sk-test', 'main'); + + expect(manager.rpc).toHaveBeenCalledOnce(); + expect(manager.rpc).toHaveBeenCalledWith('secrets.reload', {}); + }); }); describe('removeProviderKeyFromOpenClaw', () => { @@ -366,9 +384,54 @@ describe('sanitizeOpenClawConfig', () => { logSpy.mockRestore(); }); + it('sanitizes valid JSON5 instead of treating it as corrupt', async () => { + const openclawDir = join(testHome, '.openclaw'); + await mkdir(openclawDir, { recursive: true }); + const configPath = join(openclawDir, 'openclaw.json'); + await writeFile(configPath, '{\n // OpenClaw accepts comments\n commands: { restart: false, },\n}\n', 'utf8'); + const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await sanitizeOpenClawConfig(); + + const result = JSON.parse(await readFile(configPath, 'utf8')) as Record; + expect(result.commands).toEqual({ restart: false }); + expect((result.tools as Record).profile).toBe('full'); + logSpy.mockRestore(); + }); + + it('sanitizes the running Gateway snapshot without replacing it from the fallback file', async () => { + await writeOpenClawJson({ fallbackOnly: true }); + const rpc = vi.fn(async (method: string) => { + if (method === 'config.get') { + return { + raw: JSON.stringify({ gatewayOnly: true, commands: { restart: false } }), + hash: 'gateway-hash', + }; + } + if (method === 'config.set') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }); + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator({ + getStatus: () => ({ state: 'running' }), + rpc, + } as never); + + const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth'); + await sanitizeOpenClawConfig(); + + expect(rpc.mock.calls.map(([method]) => method)).toEqual(['config.get', 'config.get', 'config.set']); + const delivered = JSON.parse((rpc.mock.calls[2]?.[1] as { raw: string }).raw) as Record; + expect(delivered.gatewayOnly).toBe(true); + expect(delivered).not.toHaveProperty('fallbackOnly'); + expect(delivered.commands).toEqual({ restart: false }); + expect(await readOpenClawJson()).toEqual({ fallbackOnly: true }); + }); + it('properly sanitizes a genuinely empty {} config (fresh install)', async () => { // A fresh install with {} is a valid config — sanitize should proceed - // and enforce tools.profile, commands.restart, etc. + // and enforce the ClawX tool and skill defaults. await writeOpenClawJson({}); const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth'); @@ -901,6 +964,43 @@ describe('syncProviderConfigToOpenClaw', () => { await rm(testUserData, { recursive: true, force: true }); }); + it('mutates the running Gateway snapshot without replacing it from the fallback file', async () => { + await writeOpenClawJson({ fallbackOnly: true }); + const rpc = vi.fn(async (method: string) => { + if (method === 'config.get') { + return { + raw: JSON.stringify({ + gatewayOnly: true, + commands: { restart: false }, + models: { providers: {} }, + }), + hash: 'gateway-hash', + }; + } + if (method === 'config.set') return { ok: true }; + throw new Error(`Unexpected RPC method: ${method}`); + }); + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator({ + getStatus: () => ({ state: 'running' }), + rpc, + } as never); + + const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth'); + await syncProviderConfigToOpenClaw('custom-example', 'model-a', { + baseUrl: 'https://example.com/v1', + api: 'openai-completions', + }); + + expect(rpc.mock.calls.map(([method]) => method)).toEqual(['config.get', 'config.set']); + const delivered = JSON.parse((rpc.mock.calls[1]?.[1] as { raw: string }).raw) as Record; + expect(delivered.gatewayOnly).toBe(true); + expect(delivered).not.toHaveProperty('fallbackOnly'); + expect(delivered.commands).toEqual({ restart: false }); + expect(((delivered.models as { providers: Record }).providers)['custom-example']).toBeDefined(); + expect(await readOpenClawJson()).toEqual({ fallbackOnly: true }); + }); + it('preserves existing custom-provider model metadata during provider sync', async () => { await writeOpenClawJson({ models: { @@ -1359,6 +1459,31 @@ describe('auth-backed provider discovery', () => { }); }); + it('reads provider config from the resolved OpenClaw config path', async () => { + const configuredPath = join(testHome, 'custom-state', 'configured-openclaw.json'); + await mkdir(join(testHome, 'custom-state'), { recursive: true }); + await writeFile(configuredPath, JSON.stringify({ + agents: { defaults: { model: { primary: 'custom-resolved/model-a' } } }, + models: { providers: { 'custom-resolved': { api: 'openai-completions' } } }, + }), 'utf8'); + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_CONFIG_PATH = configuredPath; + + try { + const { getOpenClawProvidersConfig } = await import('@electron/utils/openclaw-auth'); + const result = await getOpenClawProvidersConfig(); + + expect(result.defaultModel).toBe('custom-resolved/model-a'); + expect(result.providers).toHaveProperty('custom-resolved'); + } finally { + if (previousConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + } + } + }); + it('removes all matching auth profiles for a deleted provider so it does not reappear', async () => { await writeOpenClawJson({ agents: { @@ -1482,6 +1607,37 @@ describe('auth-backed provider discovery', () => { expect(agents.list?.[0]?.model).toBeUndefined(); }); + it('propagates a coordinator failure while removing a provider', async () => { + const runningConfig = { + models: { + providers: { + 'custom-abc12345': { + baseUrl: 'https://api.example.com/v1', + api: 'openai-completions', + }, + }, + }, + agents: { list: [{ id: 'main', name: 'Main', default: true }] }, + }; + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string) => { + if (method === 'config.get') { + return { raw: JSON.stringify(runningConfig), hash: 'hash-1' }; + } + if (method === 'config.set') { + throw new Error('config.set unavailable'); + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const { removeProviderFromOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await expect(removeProviderFromOpenClaw('custom-abc12345')).rejects.toThrow('config.set unavailable'); + }); + it('removes merged and legacy minimax plugin registrations when deleting the provider', async () => { await writeOpenClawJson({ plugins: { @@ -2070,6 +2226,37 @@ describe('syncOpenAiCompatibleImageRelay', () => { expect((auth.profiles['clawx-openai-image:default'] as Record).key).toBe('sk-relay-test'); }); + it('preserves metadata for retained relay models while dropping deselected models', async () => { + await writeOpenClawJson({ + models: { + providers: { + 'clawx-openai-image': { + baseUrl: 'https://old-relay.example.com/v1', + api: 'openai-completions', + models: [ + { id: 'gpt-image-2', name: 'GPT Image 2', contextWindow: 1234 }, + { id: 'old-image', name: 'Old Image', contextWindow: 5678 }, + ], + }, + }, + }, + }); + const { syncOpenAiCompatibleImageRelay } = await import('@electron/utils/openclaw-auth'); + + await syncOpenAiCompatibleImageRelay({ + enabled: true, + baseUrl: 'https://relay.example.com', + imageModelIds: ['gpt-image-2'], + }); + + const result = await readOpenClawJson(); + const providers = (result.models as Record).providers as Record; + const imageRelay = providers['clawx-openai-image'] as Record; + expect(imageRelay.models).toEqual([ + { id: 'gpt-image-2', name: 'GPT Image 2', contextWindow: 1234 }, + ]); + }); + it('removes only the ClawX image provider when relay is disabled', async () => { await writeOpenClawJson({ models: { @@ -2080,7 +2267,15 @@ describe('syncOpenAiCompatibleImageRelay', () => { }, agents: { defaults: { - imageGenerationModel: { primary: 'clawx-openai-image/gpt-image-2', timeoutMs: 180000 }, + imageGenerationModel: { + primary: 'clawx-openai-image/gpt-image-2', + fallbacks: [ + 'clawx-openai-image/old-image', + 'google/gemini-3.1-flash-image-preview', + ], + timeoutMs: 180000, + maxPixels: 4194304, + }, }, }, plugins: { @@ -2097,7 +2292,12 @@ describe('syncOpenAiCompatibleImageRelay', () => { expect(providers.openai).toEqual({ baseUrl: 'https://api.openai.com/v1', api: 'openai-responses', models: [] }); expect(providers['clawx-openai-image']).toBeUndefined(); const defaults = (result.agents as Record).defaults as Record; - expect(defaults.imageGenerationModel).toBeUndefined(); + expect(defaults.imageGenerationModel).toEqual({ + primary: 'google/gemini-3.1-flash-image-preview', + fallbacks: [], + timeoutMs: 180000, + maxPixels: 4194304, + }); expect(result.plugins).toBeUndefined(); }); }); @@ -2319,6 +2519,34 @@ describe('batchSyncConfigFields', () => { expect(ssrfPolicy.allowIpv6UniqueLocalRange).toBe(true); }); + it('loads external inputs once when a Gateway hash conflict replays the mutator', async () => { + let setAttempts = 0; + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string) => { + if (method === 'config.get') { + return { raw: '{}', hash: `hash-${setAttempts + 1}` }; + } + if (method === 'config.set') { + setAttempts += 1; + if (setAttempts === 1) { + throw new Error('config changed since last load; re-run config.get and retry'); + } + return { ok: true }; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth'); + + await batchSyncConfigFields('new-token'); + + expect(getSettingMock.mock.calls.filter(([key]) => key === 'gatewayPort')).toHaveLength(1); + expect(getSettingMock.mock.calls.filter(([key]) => key === 'memorySearchFtsMigrationVersion')).toHaveLength(1); + }); + it('does not override explicit web_fetch SSRF policy opt-outs', async () => { await writeOpenClawJson({ gateway: { auth: { mode: 'token', token: 'old' } }, diff --git a/tests/unit/openclaw-image-generation.test.ts b/tests/unit/openclaw-image-generation.test.ts index 32323451..3a613a48 100644 --- a/tests/unit/openclaw-image-generation.test.ts +++ b/tests/unit/openclaw-image-generation.test.ts @@ -12,6 +12,8 @@ const { testHome, testUserData } = vi.hoisted(() => { }; }); +const ensureImagePluginInstalledMock = vi.hoisted(() => vi.fn()); + vi.mock('os', async () => { const actual = await vi.importActual('os'); const mocked = { @@ -42,6 +44,10 @@ vi.mock('@electron/utils/paths', async () => { }; }); +vi.mock('@electron/utils/plugin-install', () => ({ + ensureClawXOpenAiImagePluginInstalled: ensureImagePluginInstalledMock, +})); + async function writeOpenClawJson(config: unknown): Promise { const openclawDir = join(testHome, '.openclaw'); await mkdir(openclawDir, { recursive: true }); @@ -56,6 +62,8 @@ async function readOpenClawJson(): Promise> { describe('openclaw-image-generation helpers', () => { beforeEach(async () => { vi.resetModules(); + ensureImagePluginInstalledMock.mockReset(); + ensureImagePluginInstalledMock.mockResolvedValue({ installed: true }); await rm(testHome, { recursive: true, force: true }); await rm(testUserData, { recursive: true, force: true }); }); @@ -114,4 +122,121 @@ describe('openclaw-image-generation helpers', () => { }); }); + it('preserves non-UI image model fields when updating image generation settings', async () => { + await writeOpenClawJson({ + agents: { + defaults: { + imageGenerationModel: { + primary: 'openai/old-image', + timeoutMs: 30_000, + maxPixels: 4_194_304, + }, + }, + }, + }); + const { setImageGenerationConfig } = await import('@electron/utils/openclaw-image-generation'); + + await setImageGenerationConfig({ + primary: 'openai/gpt-image-2', + fallbacks: [], + timeoutMs: null, + }); + + const saved = await readOpenClawJson(); + const defaults = (saved.agents as Record).defaults as Record; + expect(defaults.imageGenerationModel).toEqual({ + primary: 'openai/gpt-image-2', + maxPixels: 4_194_304, + }); + }); + + it('updates image generation settings on the running coordinator snapshot', async () => { + await writeOpenClawJson({ localOnly: true }); + let runningConfig: Record = { + gatewayOnly: true, + agents: { defaults: { model: { primary: 'openai/gpt-4o' } } }, + }; + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string, params: unknown) => { + if (method === 'config.get') return { raw: JSON.stringify(runningConfig), hash: 'hash-1' }; + if (method === 'config.set') { + runningConfig = JSON.parse((params as { raw: string }).raw) as Record; + return { ok: true }; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const { setImageGenerationConfig } = await import('@electron/utils/openclaw-image-generation'); + + const result = await setImageGenerationConfig({ + primary: 'openai/gpt-image-2', + fallbacks: [], + timeoutMs: null, + }); + + expect(result).toEqual({ + primary: 'openai/gpt-image-2', + fallbacks: [], + timeoutMs: null, + }); + expect(runningConfig).toMatchObject({ + gatewayOnly: true, + agents: { + defaults: { + model: { primary: 'openai/gpt-4o' }, + imageGenerationModel: { primary: 'openai/gpt-image-2' }, + mediaGenerationAutoProviderFallback: false, + }, + }, + }); + expect(await readOpenClawJson()).toEqual({ localOnly: true }); + }); + + it('builds the image settings view from one authoritative config snapshot', async () => { + const runningConfig = { + agents: { + defaults: { imageGenerationModel: { primary: 'openai/gpt-image-2' } }, + list: [{ id: 'main', name: 'Main', default: true }], + }, + }; + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string) => { + if (method === 'config.get') { + return { raw: JSON.stringify(runningConfig), hash: 'hash-1' }; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const { getImageGenerationSettingsSnapshot } = await import('@electron/utils/openclaw-image-generation'); + + const snapshot = await getImageGenerationSettingsSnapshot(); + + expect(snapshot.config.primary).toBe('openai/gpt-image-2'); + expect(snapshot.defaultAgentId).toBe('main'); + expect(manager.rpc).toHaveBeenCalledOnce(); + expect(manager.rpc).toHaveBeenCalledWith('config.get', {}); + }); + + it('does not enable the relay when its plugin cannot be installed', async () => { + await writeOpenClawJson({ existing: true }); + ensureImagePluginInstalledMock.mockResolvedValue({ + installed: false, + warning: 'plugin mirror missing', + }); + const { applyOpenAiImageRelaySettings } = await import('@electron/utils/openclaw-image-generation'); + + await expect(applyOpenAiImageRelaySettings({ + enabled: true, + baseUrl: 'https://relay.example.com', + apiKey: 'sk-test', + })).rejects.toThrow('plugin mirror missing'); + + await expect(readOpenClawJson()).resolves.toEqual({ existing: true }); + }); }); diff --git a/tests/unit/openclaw-proxy.test.ts b/tests/unit/openclaw-proxy.test.ts index 5ca7963a..e7e83e55 100644 --- a/tests/unit/openclaw-proxy.test.ts +++ b/tests/unit/openclaw-proxy.test.ts @@ -1,22 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { - readOpenClawConfigMock, - writeOpenClawConfigMock, - withConfigLockMock, + mutateOpenClawConfigMock, } = vi.hoisted(() => ({ - readOpenClawConfigMock: vi.fn(), - writeOpenClawConfigMock: vi.fn(), - withConfigLockMock: vi.fn(async (fn: () => Promise) => await fn()), + mutateOpenClawConfigMock: vi.fn(), })); -vi.mock('@electron/utils/channel-config', () => ({ - readOpenClawConfig: readOpenClawConfigMock, - writeOpenClawConfig: writeOpenClawConfigMock, -})); - -vi.mock('@electron/utils/config-mutex', () => ({ - withConfigLock: withConfigLockMock, +vi.mock('@electron/gateway/config-delivery', () => ({ + mutateOpenClawConfig: mutateOpenClawConfigMock, })); vi.mock('@electron/utils/logger', () => ({ @@ -33,15 +24,26 @@ describe('syncProxyConfigToOpenClaw', () => { vi.clearAllMocks(); }); + function useCoordinatorConfig(config: Record): void { + mutateOpenClawConfigMock.mockImplementation(async ( + mutator: (snapshot: Record) => void | Promise, + ) => { + const before = JSON.stringify(config); + await mutator(config); + return JSON.stringify(config) !== before; + }); + } + it('preserves existing telegram proxy on startup-style sync when proxy is disabled', async () => { - readOpenClawConfigMock.mockResolvedValue({ + const config = { channels: { telegram: { botToken: 'token', proxy: 'socks5://127.0.0.1:7891', }, }, - }); + }; + useCoordinatorConfig(config); const { syncProxyConfigToOpenClaw } = await import('@electron/utils/openclaw-proxy'); @@ -54,18 +56,20 @@ describe('syncProxyConfigToOpenClaw', () => { proxyBypassRules: '', }); - expect(writeOpenClawConfigMock).not.toHaveBeenCalled(); + expect(config.channels.telegram.proxy).toBe('socks5://127.0.0.1:7891'); + expect(mutateOpenClawConfigMock).toHaveBeenCalledOnce(); }); it('clears telegram proxy when explicitly requested while proxy is disabled', async () => { - readOpenClawConfigMock.mockResolvedValue({ + const config = { channels: { telegram: { botToken: 'token', - proxy: 'socks5://127.0.0.1:7891', + proxy: 'socks5://127.0.0.1:7891' as string | undefined, }, }, - }); + }; + useCoordinatorConfig(config); const { syncProxyConfigToOpenClaw } = await import('@electron/utils/openclaw-proxy'); @@ -80,10 +84,7 @@ describe('syncProxyConfigToOpenClaw', () => { preserveExistingWhenDisabled: false, }); - expect(writeOpenClawConfigMock).toHaveBeenCalledTimes(1); - const updatedConfig = writeOpenClawConfigMock.mock.calls[0][0] as { - channels: { telegram: Record }; - }; - expect(updatedConfig.channels.telegram.proxy).toBeUndefined(); + expect(mutateOpenClawConfigMock).toHaveBeenCalledOnce(); + expect(config.channels.telegram.proxy).toBeUndefined(); }); }); diff --git a/tests/unit/plugin-install.test.ts b/tests/unit/plugin-install.test.ts index fb7aa245..5c6a601a 100644 --- a/tests/unit/plugin-install.test.ts +++ b/tests/unit/plugin-install.test.ts @@ -18,8 +18,10 @@ const { mockLoggerInfo, mockUpsertPluginInstallRecordsIntoSqlite, mockRemovePluginInstallRecordsFromSqlite, + mockMutateOpenClawConfig, mockHomedir, mockApp, + configState, } = vi.hoisted(() => ({ mockExistsSync: vi.fn(), mockCpSync: vi.fn(), @@ -38,11 +40,15 @@ const { mockLoggerInfo: vi.fn(), mockUpsertPluginInstallRecordsIntoSqlite: vi.fn(() => true), mockRemovePluginInstallRecordsFromSqlite: vi.fn(() => true), + mockMutateOpenClawConfig: vi.fn(), mockHomedir: vi.fn(() => '/home/test'), mockApp: { isPackaged: true, getAppPath: vi.fn(() => '/mock/app'), }, + configState: { + authoritative: {} as Record, + }, })); const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform'); @@ -106,6 +112,10 @@ vi.mock('@electron/utils/plugin-install-index', () => ({ ensureOpenClawStateDirExists: vi.fn(), })); +vi.mock('@electron/gateway/config-delivery', () => ({ + mutateOpenClawConfig: mockMutateOpenClawConfig, +})); + function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform, @@ -136,6 +146,14 @@ describe('plugin installer diagnostics', () => { mockWriteFileSync.mockImplementation(() => undefined); mockReaddirSync.mockReturnValue([]); mockRealpathSync.mockImplementation((input: string) => input); + configState.authoritative = {}; + mockMutateOpenClawConfig.mockImplementation(async ( + mutator: (config: Record) => void | Promise, + ) => { + const before = structuredClone(configState.authoritative); + await mutator(configState.authoritative); + return JSON.stringify(before) !== JSON.stringify(configState.authoritative); + }); }); afterEach(() => { @@ -192,7 +210,7 @@ describe('plugin installer diagnostics', () => { it('returns source-missing warning when bundled mirror cannot be found', async () => { const { ensurePluginInstalled } = await import('@electron/utils/plugin-install'); - const result = ensurePluginInstalled('wecom', ['/bundle/wecom'], 'WeCom'); + const result = await ensurePluginInstalled('wecom', ['/bundle/wecom'], 'WeCom'); expect(result.installed).toBe(false); expect(result.warning).toContain('Bundled WeCom plugin mirror not found'); @@ -219,7 +237,7 @@ describe('plugin installer diagnostics', () => { }); const { ensurePluginInstalled } = await import('@electron/utils/plugin-install'); - const result = ensurePluginInstalled('wecom', [sourceDir], 'WeCom'); + const result = await ensurePluginInstalled('wecom', [sourceDir], 'WeCom'); expect(result).toEqual({ installed: false, @@ -271,7 +289,7 @@ describe('plugin installer diagnostics', () => { }); const { ensurePluginInstalled } = await import('@electron/utils/plugin-install'); - const result = ensurePluginInstalled('wecom', [sourceDir], 'WeCom'); + const result = await ensurePluginInstalled('wecom', [sourceDir], 'WeCom'); expect(result.installed).toBe(false); expect(result.warning).toBe('Failed to install bundled WeCom plugin mirror'); @@ -291,26 +309,16 @@ describe('plugin installer diagnostics', () => { }); it('writes trusted SQLite metadata for mirrored official whatsapp plugin', async () => { - const configPath = '/home/test/.openclaw/openclaw.json'; const targetDir = '/home/test/.openclaw/extensions/whatsapp'; const sourceDir = '/bundle/whatsapp'; mockExistsSync.mockImplementation((input: string) => { const value = String(input); return value.includes('openclaw.plugin.json') - || value === configPath || value.includes('/bundle/whatsapp/package.json') || value.includes(`${targetDir}/package.json`); }); mockReadFileSync.mockImplementation((input: string) => { - if (String(input) === configPath) { - return JSON.stringify({ - plugins: { - allow: ['whatsapp'], - enabled: true, - }, - }); - } if (String(input).endsWith('package.json')) { return JSON.stringify({ version: '2026.6.10' }); } @@ -319,7 +327,7 @@ describe('plugin installer diagnostics', () => { mockRealpathSync.mockImplementation((input: string) => input); const { ensurePluginInstalled } = await import('@electron/utils/plugin-install'); - const result = ensurePluginInstalled('whatsapp', [sourceDir], 'WhatsApp'); + const result = await ensurePluginInstalled('whatsapp', [sourceDir], 'WhatsApp'); expect(result.installed).toBe(true); expect(mockUpsertPluginInstallRecordsIntoSqlite).toHaveBeenCalledWith({ @@ -331,29 +339,26 @@ describe('plugin installer diagnostics', () => { }); it('removes WeCom updater metadata for the patched legacy-compatible plugin id', async () => { - const configPath = '/home/test/.openclaw/openclaw.json'; const targetDir = '/home/test/.openclaw/extensions/wecom'; + configState.authoritative = { + gatewayOnly: true, + plugins: { + installs: { + wecom: { + source: 'npm', + spec: '@wecom/wecom-openclaw-plugin', + version: '2026.6.23', + }, + }, + }, + }; mockExistsSync.mockImplementation((input: string) => { const value = String(input); - return value === configPath - || value === `${targetDir}/openclaw.plugin.json` + return value === `${targetDir}/openclaw.plugin.json` || value === `${targetDir}/package.json`; }); mockReadFileSync.mockImplementation((input: string) => { - if (String(input) === configPath) { - return JSON.stringify({ - plugins: { - installs: { - wecom: { - source: 'npm', - spec: '@wecom/wecom-openclaw-plugin', - version: '2026.6.23', - }, - }, - }, - }); - } if (String(input) === `${targetDir}/package.json`) { return JSON.stringify({ version: '2026.7.2' }); } @@ -362,12 +367,10 @@ describe('plugin installer diagnostics', () => { mockRealpathSync.mockImplementation((input: string) => input); const { syncTrustedOfficialPluginInstallRecord } = await import('@electron/utils/plugin-install'); - expect(syncTrustedOfficialPluginInstallRecord('wecom', targetDir)).toBe(true); - expect(mockWriteFileSync).toHaveBeenCalledWith( - configPath, - expect.not.stringContaining('"installs"'), - 'utf-8', - ); + await expect(syncTrustedOfficialPluginInstallRecord('wecom', targetDir)).resolves.toBe(true); + expect(configState.authoritative).toEqual({ gatewayOnly: true, plugins: {} }); + expect(mockMutateOpenClawConfig).toHaveBeenCalledOnce(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); expect(mockRemovePluginInstallRecordsFromSqlite).toHaveBeenCalledWith(['wecom-openclaw-plugin']); expect(mockUpsertPluginInstallRecordsIntoSqlite).toHaveBeenCalledWith({ wecom: expect.objectContaining({ @@ -380,26 +383,23 @@ describe('plugin installer diagnostics', () => { }); it('replaces legacy Feishu npm ownership with the ClawX path mirror', async () => { - const configPath = '/home/test/.openclaw/openclaw.json'; const targetDir = '/home/test/.openclaw/extensions/feishu-openclaw-plugin'; + configState.authoritative = { + gatewayOnly: true, + plugins: { + installs: { + 'openclaw-lark': { source: 'npm', version: '2026.6.10' }, + 'feishu-openclaw-plugin': { source: 'npm', version: '2026.6.10' }, + }, + }, + }; mockExistsSync.mockImplementation((input: string) => { const value = String(input); - return value === configPath - || value === `${targetDir}/openclaw.plugin.json` + return value === `${targetDir}/openclaw.plugin.json` || value === `${targetDir}/package.json`; }); mockReadFileSync.mockImplementation((input: string) => { - if (String(input) === configPath) { - return JSON.stringify({ - plugins: { - installs: { - 'openclaw-lark': { source: 'npm', version: '2026.6.10' }, - 'feishu-openclaw-plugin': { source: 'npm', version: '2026.6.10' }, - }, - }, - }); - } if (String(input) === `${targetDir}/package.json`) { return JSON.stringify({ version: '2026.7.9' }); } @@ -407,13 +407,11 @@ describe('plugin installer diagnostics', () => { }); const { syncTrustedOfficialPluginInstallRecord } = await import('@electron/utils/plugin-install'); - expect(syncTrustedOfficialPluginInstallRecord('feishu-openclaw-plugin', targetDir)).toBe(true); + await expect(syncTrustedOfficialPluginInstallRecord('feishu-openclaw-plugin', targetDir)).resolves.toBe(true); - expect(mockWriteFileSync).toHaveBeenCalledWith( - configPath, - expect.not.stringContaining('"installs"'), - 'utf-8', - ); + expect(configState.authoritative).toEqual({ gatewayOnly: true, plugins: {} }); + expect(mockMutateOpenClawConfig).toHaveBeenCalledOnce(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); expect(mockRemovePluginInstallRecordsFromSqlite).toHaveBeenCalledWith([ 'feishu-openclaw-plugin', 'feishu', @@ -432,7 +430,8 @@ describe('plugin installer diagnostics', () => { mockExistsSync.mockReturnValue(false); const { removeTrustedOfficialPluginInstallRecord } = await import('@electron/utils/plugin-install'); - expect(removeTrustedOfficialPluginInstallRecord('whatsapp')).toBe(true); + await expect(removeTrustedOfficialPluginInstallRecord('whatsapp')).resolves.toBe(true); + expect(mockMutateOpenClawConfig).toHaveBeenCalledOnce(); expect(mockRemovePluginInstallRecordsFromSqlite).toHaveBeenCalledWith(['whatsapp']); }); diff --git a/tests/unit/preinstalled-skill.test.ts b/tests/unit/preinstalled-skill.test.ts new file mode 100644 index 00000000..64fc9849 --- /dev/null +++ b/tests/unit/preinstalled-skill.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ + homeDir: '', + resourcesDir: '', + authoritativeConfig: {} as Record, +})); + +const { mutateOpenClawConfigMock } = vi.hoisted(() => ({ + mutateOpenClawConfigMock: vi.fn(), +})); + +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + return { + ...actual, + homedir: () => state.homeDir, + }; +}); + +vi.mock('@electron/utils/paths', () => ({ + getOpenClawDir: () => '/runtime/openclaw', + getOpenClawResolvedDir: () => '/runtime/openclaw', + getResourcesDir: () => state.resourcesDir, + resolveOpenClawConfigPath: () => join(state.homeDir, '.openclaw', 'openclaw.json'), +})); + +vi.mock('@electron/gateway/config-delivery', () => ({ + mutateOpenClawConfig: mutateOpenClawConfigMock, +})); + +describe('preinstalled skill config', () => { + let root: string; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + root = mkdtempSync(join(tmpdir(), 'clawx-preinstalled-skill-')); + state.homeDir = join(root, 'home'); + state.resourcesDir = join(root, 'resources'); + state.authoritativeConfig = { gatewayOnly: true }; + mutateOpenClawConfigMock.mockImplementation(async ( + mutator: (config: Record) => void | Promise, + ) => { + await mutator(state.authoritativeConfig); + return true; + }); + + mkdirSync(join(state.resourcesDir, 'skills'), { recursive: true }); + mkdirSync(join(state.resourcesDir, 'preinstalled-skills', 'example'), { recursive: true }); + writeFileSync( + join(state.resourcesDir, 'skills', 'preinstalled-manifest.json'), + JSON.stringify({ skills: [{ slug: 'example', version: '1.0.0', autoEnable: true }] }), + ); + writeFileSync(join(state.resourcesDir, 'preinstalled-skills', 'example', 'SKILL.md'), '# Example\n'); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('auto-enables installed skills through the coordinator authoritative snapshot', async () => { + const { ensurePreinstalledSkillsInstalled } = await import('@electron/utils/skill-config'); + await ensurePreinstalledSkillsInstalled(); + + expect(state.authoritativeConfig).toEqual({ + gatewayOnly: true, + skills: { entries: { example: { enabled: true } } }, + }); + expect(mutateOpenClawConfigMock).toHaveBeenCalledOnce(); + expect(existsSync(join(state.homeDir, '.openclaw', 'openclaw.json'))).toBe(false); + expect(readFileSync( + join(state.homeDir, '.openclaw', 'skills', 'example', 'SKILL.md'), + 'utf8', + )).toBe('# Example\n'); + }); +}); diff --git a/tests/unit/provider-runtime-sync.test.ts b/tests/unit/provider-runtime-sync.test.ts index baecc972..49f8cc1d 100644 --- a/tests/unit/provider-runtime-sync.test.ts +++ b/tests/unit/provider-runtime-sync.test.ts @@ -102,15 +102,22 @@ function createProvider(overrides: Partial = {}): ProviderConfig }; } -function createGateway(state: 'running' | 'stopped' = 'running'): Pick { +function createGateway(state: 'running' | 'stopped' = 'running') { return { debouncedReload: vi.fn(), debouncedRestart: vi.fn(), + restart: vi.fn(), getStatus: vi.fn(() => ({ state } as ReturnType)), }; } -describe('provider-runtime-sync refresh strategy', () => { +function expectNoGatewayLifecycleCalls(gateway: ReturnType): void { + expect(gateway.debouncedReload).not.toHaveBeenCalled(); + expect(gateway.debouncedRestart).not.toHaveBeenCalled(); + expect(gateway.restart).not.toHaveBeenCalled(); +} + +describe('provider-runtime-sync config delivery', () => { beforeEach(() => { vi.clearAllMocks(); mocks.getProviderAccount.mockResolvedValue(null); @@ -138,20 +145,25 @@ describe('provider-runtime-sync refresh strategy', () => { mocks.listAgentsSnapshot.mockResolvedValue({ agents: [] }); }); - it('uses debouncedReload after saving provider config', async () => { + it('does not schedule an independent reload or restart after saving provider config', async () => { const gateway = createGateway('running'); await syncSavedProviderToRuntime(createProvider(), undefined, gateway as GatewayManager); - expect(gateway.debouncedReload).toHaveBeenCalledTimes(1); - expect(gateway.debouncedRestart).not.toHaveBeenCalled(); + expectNoGatewayLifecycleCalls(gateway); }); - it('uses debouncedRestart after deleting provider config', async () => { + it('propagates per-agent model registry sync failures after saving provider config', async () => { + mocks.listAgentsSnapshot.mockRejectedValueOnce(new Error('models.json sync unavailable')); + + await expect(syncSavedProviderToRuntime(createProvider(), undefined)) + .rejects.toThrow('models.json sync unavailable'); + }); + + it('does not schedule an independent reload or restart after deleting provider config', async () => { const gateway = createGateway('running'); await syncDeletedProviderToRuntime(createProvider(), 'moonshot', gateway as GatewayManager); - expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1); - expect(gateway.debouncedReload).not.toHaveBeenCalled(); + expectNoGatewayLifecycleCalls(gateway); }); it('removes both runtime and stored account keys when deleting a custom provider', async () => { @@ -167,7 +179,7 @@ describe('provider-runtime-sync refresh strategy', () => { expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('custom-moonshot'); expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('moonshot-cn'); expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledTimes(2); - expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1); + expectNoGatewayLifecycleCalls(gateway); }); it('also removes bare openai config when deleting Codex OAuth without an API key', async () => { @@ -203,7 +215,7 @@ describe('provider-runtime-sync refresh strategy', () => { expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('openai'); expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('openai-oauth-1'); expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('openai'); - expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1); + expectNoGatewayLifecycleCalls(gateway); }); it('only clears the api-key profile when deleting a provider api key', async () => { @@ -218,20 +230,18 @@ describe('provider-runtime-sync refresh strategy', () => { expect(mocks.removeProviderFromOpenClaw).not.toHaveBeenCalled(); }); - it('uses debouncedReload after switching default provider when gateway is running', async () => { + it('does not schedule an independent reload or restart after switching the default provider', async () => { const gateway = createGateway('running'); await syncDefaultProviderToRuntime('moonshot', gateway as GatewayManager); - expect(gateway.debouncedReload).toHaveBeenCalledTimes(1); - expect(gateway.debouncedRestart).not.toHaveBeenCalled(); + expectNoGatewayLifecycleCalls(gateway); }); it('skips refresh after switching default provider when gateway is stopped', async () => { const gateway = createGateway('stopped'); await syncDefaultProviderToRuntime('moonshot', gateway as GatewayManager); - expect(gateway.debouncedReload).not.toHaveBeenCalled(); - expect(gateway.debouncedRestart).not.toHaveBeenCalled(); + expectNoGatewayLifecycleCalls(gateway); }); it('uses gpt-5.6-sol as the browser OAuth default model for OpenAI', async () => { @@ -364,7 +374,7 @@ describe('provider-runtime-sync refresh strategy', () => { api: 'openai-completions', }), ); - expect(gateway.debouncedReload).toHaveBeenCalledTimes(1); + expectNoGatewayLifecycleCalls(gateway); }); it('syncs Ollama as default provider with correct baseUrl and api protocol', async () => { @@ -422,7 +432,7 @@ describe('provider-runtime-sync refresh strategy', () => { ); // Should NOT call the non-override path expect(mocks.setOpenClawDefaultModel).not.toHaveBeenCalled(); - expect(gateway.debouncedReload).toHaveBeenCalledTimes(1); + expectNoGatewayLifecycleCalls(gateway); }); it('removes Ollama provider from runtime on delete', async () => { @@ -439,6 +449,14 @@ describe('provider-runtime-sync refresh strategy', () => { expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('ollama-ollamafd'); expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('ollamafd'); - expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1); + expectNoGatewayLifecycleCalls(gateway); + }); + + it('does not schedule an independent reload or restart after updating provider config', async () => { + const gateway = createGateway('running'); + + await syncUpdatedProviderToRuntime(createProvider(), undefined, gateway as GatewayManager); + + expectNoGatewayLifecycleCalls(gateway); }); }); diff --git a/tests/unit/run-segment-indices.test.ts b/tests/unit/run-segment-indices.test.ts deleted file mode 100644 index 4e7d727e..00000000 --- a/tests/unit/run-segment-indices.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - buildRunSegmentMessageIndices, - findReplyMessageIndex, - getPostTriggerSegmentMessages, - getRunSegmentMessages, - hasActiveStreamingReplyInRun, - segmentHasFinalReply, -} from '@/pages/Chat/task-visualization'; -import type { RawMessage } from '@/stores/chat'; - -describe('buildRunSegmentMessageIndices', () => { - it('marks assistant messages between real user turns', () => { - const messages: RawMessage[] = [ - { role: 'user', content: 'first question' }, - { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'exec', input: {} }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'image', input: {} }] }, - { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, - { role: 'user', content: 'follow up' }, - { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, - ]; - const nextUserMessageIndexes = [4, -1, -1, -1, -1, -1]; - const indices = buildRunSegmentMessageIndices( - messages, - nextUserMessageIndexes, - (message) => message.role === 'user', - ); - - expect(indices.has(1)).toBe(true); - expect(indices.has(2)).toBe(true); - expect(indices.has(3)).toBe(true); - expect(indices.has(5)).toBe(true); - expect(indices.has(0)).toBe(false); - expect(indices.has(4)).toBe(false); - }); - - it('folds leading assistant orphans before the first user in a paginated suffix', () => { - const messages: RawMessage[] = [ - { role: 'assistant', content: [{ type: 'toolCall', id: 't1', name: 'exec', input: {} }] }, - { role: 'assistant', content: [{ type: 'toolCall', id: 't2', name: 'image', input: {} }] }, - { role: 'user', content: 'question fell off the earlier page' }, - { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, - ]; - const nextUserMessageIndexes = [-1, -1, -1, -1]; - const indices = buildRunSegmentMessageIndices( - messages, - nextUserMessageIndexes, - (message) => message.role === 'user', - ); - - expect(indices.has(0)).toBe(true); - expect(indices.has(1)).toBe(true); - expect(indices.has(3)).toBe(true); - expect(indices.has(2)).toBe(false); - }); -}); - -describe('getPostTriggerSegmentMessages vs getRunSegmentMessages', () => { - const isUser = (message: RawMessage) => message.role === 'user'; - - it('keeps lifecycle segment empty while graph segment includes paginated orphans', () => { - const messages: RawMessage[] = [ - { role: 'assistant', content: [{ type: 'text', text: 'prior answer' }] }, - { role: 'user', content: 'follow up' }, - ]; - - expect(getPostTriggerSegmentMessages(messages, 1, -1)).toEqual([]); - expect(getRunSegmentMessages(messages, 1, -1, isUser)).toEqual([ - { role: 'assistant', content: [{ type: 'text', text: 'prior answer' }] }, - ]); - }); - - it('does not attach a prior turn assistant when an earlier user exists in the window', () => { - const messages: RawMessage[] = [ - { role: 'user', content: 'first' }, - { role: 'assistant', content: [{ type: 'text', text: 'first answer' }] }, - { role: 'user', content: 'second' }, - ]; - - expect(getPostTriggerSegmentMessages(messages, 2, -1)).toEqual([]); - expect(getRunSegmentMessages(messages, 2, -1, isUser)).toEqual([]); - }); -}); - -describe('segmentHasFinalReply', () => { - it('returns true when a text reply follows all tool calls', () => { - const segment: RawMessage[] = [ - { role: 'assistant', content: [{ type: 'text', text: 'Working on it.' }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'exec', input: {} }] }, - { role: 'assistant', content: [{ type: 'text', text: '执行完成 ✅' }] }, - ]; - expect(segmentHasFinalReply(segment)).toBe(true); - }); - - it('returns false for narration before tools while the chain is still open', () => { - const segment: RawMessage[] = [ - { role: 'assistant', content: [{ type: 'text', text: 'Let me fetch that.' }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'exec', input: {} }] }, - ]; - expect(segmentHasFinalReply(segment)).toBe(false); - }); -}); - -describe('findReplyMessageIndex / hasActiveStreamingReplyInRun', () => { - it('protects a history reply from fold when the run is open but not streaming', () => { - const postTrigger: RawMessage[] = [ - { role: 'assistant', content: [{ type: 'text', text: '你好,我在。' }] }, - ]; - - expect(hasActiveStreamingReplyInRun(true, false, null)).toBe(false); - expect(findReplyMessageIndex(postTrigger, false)).toBe(0); - expect(findReplyMessageIndex(postTrigger, true)).toBe(-1); - }); - - it('folds history when a stream bubble is active', () => { - const postTrigger: RawMessage[] = [ - { role: 'assistant', content: [{ type: 'text', text: 'partial' }] }, - ]; - - expect(hasActiveStreamingReplyInRun(true, true, null)).toBe(true); - expect(findReplyMessageIndex(postTrigger, true)).toBe(-1); - }); -}); diff --git a/tests/unit/sanitize-config.test.ts b/tests/unit/sanitize-config.test.ts index 305fc6f4..0e5abbe0 100644 --- a/tests/unit/sanitize-config.test.ts +++ b/tests/unit/sanitize-config.test.ts @@ -1,9 +1,8 @@ /** * Tests for openclaw.json config sanitization before Gateway start. * - * The sanitizeOpenClawConfig() function in openclaw-auth.ts relies on - * Electron-specific helpers (readOpenClawJson / writeOpenClawJson) that - * read from ~/.openclaw/openclaw.json. To avoid mocking Electron + the + * The sanitizeOpenClawConfig() function in openclaw-auth.ts relies on the + * Main-owned config-delivery coordinator. To avoid mocking Electron + the * real HOME directory, this test uses a standalone version of the * sanitization logic that mirrors the production code exactly, operating * on a temp directory with real file I/O. diff --git a/tests/unit/session-label-fetch.test.ts b/tests/unit/session-label-fetch.test.ts deleted file mode 100644 index 259b5342..00000000 --- a/tests/unit/session-label-fetch.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -const gatewayRpcMock = vi.fn(); - -vi.mock('@/lib/host-api', () => ({ - hostApi: { - gateway: { - rpc: async (method: string, params?: unknown, timeoutMs?: number) => { - const result = await gatewayRpcMock( - method, - params, - timeoutMs, - ) as { success?: boolean; result?: unknown; error?: string }; - if (result?.success === false) { - throw new Error(result.error || `RPC ${method} failed`); - } - return result?.result; - }, - }, - }, -})); - -vi.mock('@/stores/chat/helpers', () => ({ - getCanonicalPrefixFromSessions: () => 'agent:main', - getMessageText: (content: unknown) => typeof content === 'string' ? content : '', - toMs: (v: unknown) => typeof v === 'number' ? v : 0, -})); - -describe('session label fetch concurrency', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('skips sessions with existing frontend or backend labels', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - success: true, - result: { - sessions: [ - { key: 'agent:main:session-a', label: 'Backend label', updatedAt: 1000 }, - { key: 'agent:main:session-b', displayName: 'Session B', updatedAt: 1001 }, - { key: 'agent:main:main', displayName: 'Main', updatedAt: 1002 }, - ], - }, - }; - } - if (method === 'chat.history') { - return { - success: true, - result: { - messages: [{ role: 'user', content: 'hello', timestamp: Date.now() }], - }, - }; - } - return { success: false }; - }); - - vi.resetModules(); - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const state = { - currentSessionKey: 'agent:main:main', - messages: [], - sessions: [], - sessionLabels: { 'agent:main:session-b': 'Already labeled' }, - sessionLastActivity: {}, - }; - const set = vi.fn(); - const get = vi.fn().mockReturnValue({ - ...state, - loadHistory: vi.fn(), - }); - - const actions = createSessionActions(set as never, get as never); - await actions.loadSessions(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - const chatHistoryCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history'); - expect(chatHistoryCalls).toHaveLength(0); - }); - - it('does not re-request unchanged sessions after an empty hydration result', async () => { - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - success: true, - result: { - sessions: [ - { key: 'agent:main:session-a', displayName: 'Session A', updatedAt: 1000 }, - { key: 'agent:main:main', displayName: 'Main', updatedAt: 1001 }, - ], - }, - }; - } - if (method === 'chat.history') { - return { - success: true, - result: { - messages: [], - }, - }; - } - return { success: false }; - }); - - vi.resetModules(); - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const state = { - currentSessionKey: 'agent:main:main', - messages: [], - sessions: [], - sessionLabels: {}, - sessionLastActivity: {}, - }; - const set = vi.fn(); - const get = vi.fn().mockReturnValue({ - ...state, - loadHistory: vi.fn(), - }); - - const actions = createSessionActions(set as never, get as never); - await actions.loadSessions(); - await new Promise((resolve) => setTimeout(resolve, 10)); - await actions.loadSessions(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - const chatHistoryCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history'); - expect(chatHistoryCalls).toHaveLength(1); - }); - - it('re-requests a session when updatedAt changes after an empty result', async () => { - let updatedAt = 1000; - gatewayRpcMock.mockImplementation(async (method: string) => { - if (method === 'sessions.list') { - return { - success: true, - result: { - sessions: [ - { key: 'agent:main:session-a', displayName: 'Session A', updatedAt }, - { key: 'agent:main:main', displayName: 'Main', updatedAt: 1001 }, - ], - }, - }; - } - if (method === 'chat.history') { - return { - success: true, - result: { - messages: [], - }, - }; - } - return { success: false }; - }); - - vi.resetModules(); - const { createSessionActions } = await import('@/stores/chat/session-actions'); - const state = { - currentSessionKey: 'agent:main:main', - messages: [], - sessions: [], - sessionLabels: {}, - sessionLastActivity: {}, - }; - const set = vi.fn(); - const get = vi.fn().mockReturnValue({ - ...state, - loadHistory: vi.fn(), - }); - - const actions = createSessionActions(set as never, get as never); - await actions.loadSessions(); - await new Promise((resolve) => setTimeout(resolve, 10)); - updatedAt = 2000; - await actions.loadSessions(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - const chatHistoryCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history'); - expect(chatHistoryCalls).toHaveLength(2); - }); -}); diff --git a/tests/unit/sidebar-session-buckets.test.ts b/tests/unit/sidebar-session-buckets.test.ts index 396fac32..3c0e771f 100644 --- a/tests/unit/sidebar-session-buckets.test.ts +++ b/tests/unit/sidebar-session-buckets.test.ts @@ -57,7 +57,6 @@ function seedSidebarState(renameSession = vi.fn().mockResolvedValue(undefined)) sessionLabels: {}, sessionLastActivity: { [sidebarSessionKey]: 1 }, renameSession, - loadHistory: vi.fn().mockResolvedValue(undefined), loadSessions: vi.fn().mockResolvedValue(undefined), }); } @@ -239,17 +238,12 @@ describe('sidebar session helpers', () => { expect(screen.queryByTestId(`sidebar-session-time-${sidebarSessionKey}`)).not.toBeInTheDocument(); }); - it('marks an unread row read before reloading the selected session and restores its timestamp', () => { + it('marks the current session read without loading legacy history and restores its timestamp', () => { const originalMarkRead = useSessionAttentionStore.getState().markRead; const markRead = vi.fn((sessionKey: string) => originalMarkRead(sessionKey)); - const loadHistory = vi.fn(() => { - expect(useSessionAttentionStore.getState().bySessionKey[sidebarSessionKey]?.unread).toBe(false); - return Promise.resolve(); - }); + const switchSession = vi.fn(); seedSidebarState(); - useChatStore.setState({ - loadHistory, - }); + useChatStore.setState({ switchSession }); useSessionAttentionStore.setState({ bySessionKey: { [sidebarSessionKey]: { observedBusy: false, unread: true } }, markRead, @@ -259,10 +253,29 @@ describe('sidebar session helpers', () => { fireEvent.click(screen.getByTestId(`sidebar-session-${sidebarSessionKey}`)); expect(markRead).toHaveBeenCalledWith(sidebarSessionKey); - expect(loadHistory).toHaveBeenCalledWith(false); + expect(switchSession).not.toHaveBeenCalled(); expect(screen.getByTestId(`sidebar-session-time-${sidebarSessionKey}`)).toBeInTheDocument(); }); + it('loads only the session catalog when the Gateway becomes ready', async () => { + const loadSessions = vi.fn().mockResolvedValue(undefined); + seedSidebarState(); + useChatStore.setState({ loadSessions }); + useGatewayStore.setState({ + status: { + state: 'running', + gatewayReady: true, + port: 18789, + pid: 1234, + connectedAt: 5678, + }, + }); + + renderSidebar(); + + await waitFor(() => expect(loadSessions).toHaveBeenCalledTimes(1)); + }); + it('does not treat a retained current session as visible or read on Settings', () => { seedSidebarState(); useSessionAttentionStore.setState({ @@ -349,6 +362,33 @@ describe('sidebar session helpers', () => { expect(renameSession).not.toHaveBeenCalled(); }); + it('keeps the single-session delete dialog open when deletion fails', async () => { + const deleteSession = vi.fn().mockResolvedValue({ success: false, error: 'locked' }); + seedSidebarState(); + useChatStore.setState({ deleteSession }); + renderSidebar(); + + fireEvent.click(screen.getByTestId(`sidebar-session-delete-${sidebarSessionKey}`)); + fireEvent.click(screen.getByTestId('confirm-dialog-confirm-button')); + + await waitFor(() => expect(deleteSession).toHaveBeenCalledWith(sidebarSessionKey)); + expect(screen.getByTestId('confirm-dialog-confirm-button')).toBeInTheDocument(); + }); + + it('closes the single-session delete dialog after deletion succeeds', async () => { + const deleteSession = vi.fn().mockResolvedValue({ success: true }); + seedSidebarState(); + useChatStore.setState({ deleteSession }); + renderSidebar(); + + fireEvent.click(screen.getByTestId(`sidebar-session-delete-${sidebarSessionKey}`)); + fireEvent.click(screen.getByTestId('confirm-dialog-confirm-button')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-dialog-confirm-button')).not.toBeInTheDocument(); + }); + }); + it('keeps rename controls active when focus moves to cancel and cancels on click', () => { const renameSession = vi.fn().mockResolvedValue(undefined); seedSidebarState(renameSession); diff --git a/tests/unit/skill-config.test.ts b/tests/unit/skill-config.test.ts new file mode 100644 index 00000000..62dd4140 --- /dev/null +++ b/tests/unit/skill-config.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ + authoritativeConfig: {} as Record, +})); + +const { mutateOpenClawConfigMock, readOpenClawConfigSnapshotMock, readFileMock, writeFileMock } = vi.hoisted(() => ({ + mutateOpenClawConfigMock: vi.fn(), + readOpenClawConfigSnapshotMock: vi.fn(), + readFileMock: vi.fn(), + writeFileMock: vi.fn(), +})); + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises'); + return { + ...actual, + readFile: readFileMock, + writeFile: writeFileMock, + }; +}); + +vi.mock('@electron/gateway/config-delivery', () => ({ + mutateOpenClawConfig: mutateOpenClawConfigMock, + readOpenClawConfigSnapshot: readOpenClawConfigSnapshotMock, +})); + +vi.mock('@electron/utils/paths', () => ({ + getOpenClawDir: () => '/runtime/openclaw', + getOpenClawResolvedDir: () => '/runtime/openclaw', + getResourcesDir: () => '/resources', + resolveOpenClawConfigPath: () => '/configured/openclaw.json5', +})); + +describe('skill config mutations', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + state.authoritativeConfig = {}; + readFileMock.mockResolvedValue(JSON.stringify({ staleLocalValue: true })); + writeFileMock.mockResolvedValue(undefined); + readOpenClawConfigSnapshotMock.mockImplementation(async () => ({ + config: structuredClone(state.authoritativeConfig), + exists: true, + })); + mutateOpenClawConfigMock.mockImplementation(async ( + mutator: (config: Record) => void | Promise, + ) => { + const before = structuredClone(state.authoritativeConfig); + await mutator(state.authoritativeConfig); + return JSON.stringify(before) !== JSON.stringify(state.authoritativeConfig); + }); + }); + + it('updates the coordinator authoritative snapshot without replacing it from a local read', async () => { + state.authoritativeConfig = { + gatewayOnly: true, + skills: { + entries: { + existing: { enabled: false }, + }, + }, + }; + + const { updateSkillConfig } = await import('@electron/utils/skill-config'); + const result = await updateSkillConfig('new-skill', { enabled: true, apiKey: ' key ' }); + + expect(result).toEqual({ success: true }); + expect(state.authoritativeConfig).toEqual({ + gatewayOnly: true, + skills: { + entries: { + existing: { enabled: false }, + 'new-skill': { enabled: true, apiKey: 'key' }, + }, + }, + }); + expect(mutateOpenClawConfigMock).toHaveBeenCalledOnce(); + expect(readFileMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('counts removals from the coordinator authoritative snapshot', async () => { + state.authoritativeConfig = { + skills: { + entries: { + present: { enabled: true }, + keep: { apiKey: 'secret' }, + }, + }, + }; + + const { removeSkillConfigs } = await import('@electron/utils/skill-config'); + const result = await removeSkillConfigs([' present ', 'missing']); + + expect(result).toEqual({ success: true, removed: 1 }); + expect(state.authoritativeConfig).toEqual({ + skills: { + entries: { + keep: { apiKey: 'secret' }, + }, + }, + }); + expect(mutateOpenClawConfigMock).toHaveBeenCalledOnce(); + expect(readFileMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('reads skill config from the coordinator snapshot instead of the local file', async () => { + state.authoritativeConfig = { + skills: { entries: { authoritative: { enabled: true } } }, + }; + const { getSkillConfig } = await import('@electron/utils/skill-config'); + + await expect(getSkillConfig('authoritative')).resolves.toEqual({ enabled: true }); + expect(readOpenClawConfigSnapshotMock).toHaveBeenCalledOnce(); + expect(readFileMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/stores.test.ts b/tests/unit/stores.test.ts index 00b5365f..14267f30 100644 --- a/tests/unit/stores.test.ts +++ b/tests/unit/stores.test.ts @@ -133,9 +133,17 @@ describe('Gateway Store', () => { it('should proxy gateway rpc through ipc', async () => { hostApiMock.gateway.rpc.mockResolvedValueOnce({ ok: true }); - const result = await useGatewayStore.getState().rpc<{ ok: boolean }>('chat.history', { limit: 10 }, 5000); + const result = await useGatewayStore.getState().rpc<{ ok: boolean }>( + 'sessions.list', + { includeDerivedTitles: true }, + 5000, + ); expect(result.ok).toBe(true); - expect(hostApiMock.gateway.rpc).toHaveBeenCalledWith('chat.history', { limit: 10 }, 5000); + expect(hostApiMock.gateway.rpc).toHaveBeenCalledWith( + 'sessions.list', + { includeDerivedTitles: true }, + 5000, + ); }); }); diff --git a/tests/unit/streamdown-config.test.tsx b/tests/unit/streamdown-config.test.tsx new file mode 100644 index 00000000..ce424dcf --- /dev/null +++ b/tests/unit/streamdown-config.test.tsx @@ -0,0 +1,68 @@ +import { render } from '@testing-library/react'; +import { defaultRehypePlugins, Streamdown } from 'streamdown'; +import { + streamdownAnimation, + streamdownControls, + streamdownLinkSafety, + streamdownPlugins, + streamdownRehypePlugins, +} from '@/components/markdown/streamdown-config'; + +describe('shared Streamdown configuration', () => { + it('enables code, math, and CJK without Mermaid', () => { + expect(streamdownPlugins).toHaveProperty('code'); + expect(streamdownPlugins).toHaveProperty('math'); + expect(streamdownPlugins).toHaveProperty('cjk'); + expect(streamdownPlugins).not.toHaveProperty('mermaid'); + expect(streamdownPlugins.code?.getThemes()).toEqual(['github-light', 'github-dark']); + }); + + it('retains exactly the safe default rehype plugins', () => { + expect(streamdownRehypePlugins).toHaveLength(2); + expect(streamdownRehypePlugins).toEqual(expect.arrayContaining([ + defaultRehypePlugins.sanitize, + defaultRehypePlugins.harden, + ])); + expect(streamdownRehypePlugins).not.toContain(defaultRehypePlugins.raw); + }); + + it('renders single-dollar inline math', () => { + const { container } = render( + + {'$x$'} + , + ); + + expect(container.querySelector('.katex')).toBeInTheDocument(); + }); + + it('keeps raw HTML as literal text', () => { + const source = ''; + const { container } = render( + + {source} + , + ); + + expect(container).toHaveTextContent(source); + expect(container.querySelector('script')).not.toBeInTheDocument(); + }); + + it('enables only code copying and disables link safety', () => { + expect(streamdownControls).toEqual({ + code: { copy: true, download: false }, + mermaid: false, + table: false, + }); + expect(streamdownLinkSafety).toEqual({ enabled: false }); + }); + + it('uses the shared word animation settings', () => { + expect(streamdownAnimation).toEqual({ + animation: 'fadeIn', + duration: 140, + stagger: 0, + sep: 'word', + }); + }); +}); diff --git a/tests/unit/task-visualization.test.ts b/tests/unit/task-visualization.test.ts deleted file mode 100644 index 89acf5cd..00000000 --- a/tests/unit/task-visualization.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -// Legacy Gateway Chat helper coverage retained while non-ACP migration cleanup is still in progress. -import { describe, expect, it } from 'vitest'; -import { - deriveRuntimeTaskSteps, - deriveTaskSteps, - findReplyMessageIndex, - parseSubagentCompletionInfo, - segmentHasFinalReply, -} from '@/pages/Chat/task-visualization'; -import { stripProcessMessagePrefix } from '@/pages/Chat/message-utils'; -import { applyRuntimeEventToRuns } from '@/stores/chat/runtime-graph'; -import type { RawMessage, ToolStatus } from '@/stores/chat'; - -describe('runtime graph state', () => { - it('keeps distinct runtime tool updates for the same tool call', () => { - const started = applyRuntimeEventToRuns({}, { - type: 'tool.started', - runId: 'run-1', - toolCallId: 'call-1', - name: 'exec', - }); - const firstUpdate = applyRuntimeEventToRuns(started, { - type: 'tool.updated', - runId: 'run-1', - toolCallId: 'call-1', - name: 'exec', - partialResult: 'step 1', - }); - const secondUpdate = applyRuntimeEventToRuns(firstUpdate, { - type: 'tool.updated', - runId: 'run-1', - toolCallId: 'call-1', - name: 'exec', - partialResult: 'step 2', - }); - const duplicateSecondUpdate = applyRuntimeEventToRuns(secondUpdate, { - type: 'tool.updated', - runId: 'run-1', - toolCallId: 'call-1', - name: 'exec', - partialResult: 'step 2', - }); - - expect(secondUpdate['run-1'].events).toHaveLength(3); - expect(duplicateSecondUpdate['run-1'].events).toHaveLength(3); - }); - - it('does not drop full-text assistant deltas that do not extend the previous prefix', () => { - const first = applyRuntimeEventToRuns({}, { - type: 'assistant.delta', - runId: 'run-1', - text: 'hello', - }); - const second = applyRuntimeEventToRuns(first, { - type: 'assistant.delta', - runId: 'run-1', - text: 'corrected', - }); - - expect(second['run-1'].assistantText).toBe('corrected'); - }); -}); - -describe('deriveTaskSteps', () => { - it('projects runtime tool events into active execution graph steps', () => { - const steps = deriveRuntimeTaskSteps({ - runId: 'run-1', - status: 'running', - assistantText: '', - thinkingText: '', - events: [ - { type: 'run.started', runId: 'run-1', sessionKey: 'agent:main:main' }, - { type: 'tool.started', runId: 'run-1', sessionKey: 'agent:main:main', toolCallId: 'call-1', name: 'read', args: { filePath: '/tmp/demo.md' } }, - { type: 'command.output', runId: 'run-1', sessionKey: 'agent:main:main', toolCallId: 'call-1', itemId: 'cmd-1', title: 'exec output', output: 'Scanning workspace', status: 'running', phase: 'update' }, - { type: 'tool.completed', runId: 'run-1', sessionKey: 'agent:main:main', toolCallId: 'call-1', name: 'read', result: { summary: 'Done' }, isError: false }, - ], - }); - - expect(steps).toEqual([ - expect.objectContaining({ - id: 'call-1', - label: 'read', - status: 'completed', - kind: 'tool', - }), - expect.objectContaining({ - id: 'cmd-1', - label: 'exec output', - status: 'running', - kind: 'message', - detail: 'Scanning workspace', - }), - ]); - }); - it('builds running steps from streaming tool status without exposing chain-of-thought', () => { - const streamingTools: ToolStatus[] = [ - { - name: 'web_search', - status: 'running', - updatedAt: Date.now(), - summary: 'Searching docs', - }, - ]; - - const steps = deriveTaskSteps({ - messages: [], - streamingMessage: { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Compare a few approaches before coding.' }, - { type: 'tool_use', id: 'tool-1', name: 'web_search', input: { query: 'openclaw task list' } }, - ], - }, - streamingTools, - }); - - expect(steps).toEqual([ - expect.objectContaining({ - label: 'web_search', - status: 'running', - kind: 'tool', - }), - ]); - }); - - it('keeps completed tool steps visible while a later tool is still streaming', () => { - const steps = deriveTaskSteps({ - messages: [ - { - role: 'assistant', - id: 'assistant-history', - content: [ - { type: 'tool_use', id: 'tool-read', name: 'read', input: { filePath: '/tmp/a.md' } }, - ], - }, - ], - streamingMessage: { - role: 'assistant', - content: [ - { type: 'tool_use', id: 'tool-grep', name: 'grep', input: { pattern: 'TODO' } }, - ], - }, - streamingTools: [ - { - toolCallId: 'tool-grep', - name: 'grep', - status: 'running', - updatedAt: Date.now(), - summary: 'Scanning files', - }, - ], - }); - - expect(steps).toEqual([ - expect.objectContaining({ - id: 'tool-read', - label: 'read', - status: 'completed', - kind: 'tool', - }), - expect.objectContaining({ - id: 'tool-grep', - label: 'grep', - status: 'running', - kind: 'tool', - }), - ]); - }); - - it('upgrades a completed historical tool step when streaming status reports a later state', () => { - const steps = deriveTaskSteps({ - messages: [ - { - role: 'assistant', - id: 'assistant-history', - content: [ - { type: 'tool_use', id: 'tool-read', name: 'read', input: { filePath: '/tmp/a.md' } }, - ], - }, - ], - streamingMessage: null, - streamingTools: [ - { - toolCallId: 'tool-read', - name: 'read', - status: 'error', - updatedAt: Date.now(), - summary: 'Permission denied', - }, - ], - }); - - expect(steps).toEqual([ - expect.objectContaining({ - id: 'tool-read', - label: 'read', - status: 'error', - kind: 'tool', - detail: 'Permission denied', - }), - ]); - }); - - it('keeps all steps when the execution graph exceeds the previous max length', () => { - const messages: RawMessage[] = Array.from({ length: 9 }, (_, index) => ({ - role: 'assistant', - id: `assistant-${index}`, - content: [ - { type: 'tool_use', id: `tool-${index}`, name: `read_${index}`, input: { filePath: `/tmp/${index}.md` } }, - ], - })); - - const steps = deriveTaskSteps({ - messages, - streamingMessage: { - role: 'assistant', - content: [ - { type: 'tool_use', id: 'tool-live', name: 'grep_live', input: { pattern: 'TODO' } }, - ], - }, - streamingTools: [ - { - toolCallId: 'tool-live', - name: 'grep_live', - status: 'running', - updatedAt: Date.now(), - summary: 'Scanning current workspace', - }, - ], - }); - - expect(steps).toHaveLength(10); - expect(steps[0]).toEqual(expect.objectContaining({ - id: 'tool-0', - label: 'read_0', - status: 'completed', - })); - expect(steps.at(-1)).toEqual(expect.objectContaining({ - id: 'tool-live', - label: 'grep_live', - status: 'running', - })); - }); - - it('keeps recent completed tool steps from assistant history', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'assistant-1', - content: [ - { type: 'thinking', thinking: 'Reviewing the code path.' }, - { type: 'tool_use', id: 'tool-2', name: 'read_file', input: { path: 'src/App.tsx' } }, - ], - }, - ]; - - const steps = deriveTaskSteps({ - messages, - streamingMessage: null, - streamingTools: [], - }); - - expect(steps).toEqual([ - expect.objectContaining({ - id: 'tool-2', - label: 'read_file', - status: 'completed', - kind: 'tool', - }), - ]); - }); - - it('does not expose streaming chain-of-thought in the execution graph', () => { - const steps = deriveTaskSteps({ - messages: [], - streamingMessage: { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Reviewing X.' }, - { type: 'thinking', thinking: 'Reviewing X. Comparing Y.' }, - { type: 'thinking', thinking: 'Reviewing X. Comparing Y. Drafting answer.' }, - ], - }, - streamingTools: [], - }); - - expect(steps).toEqual([]); - }); - - it('skips internal assistant turns and hides NO_REPLY from the execution graph', () => { - const steps = deriveTaskSteps({ - messages: [ - { - role: 'assistant', - content: [{ type: 'thinking', thinking: 'Continue the OpenClaw runtime event internally.' }], - }, - { - role: 'assistant', - content: [{ type: 'text', text: 'NO_REPLY' }], - }, - { - role: 'assistant', - content: [ - { type: 'tool_use', id: 'tool-image', name: 'image_generate', input: { prompt: 'astronaut' } }, - ], - }, - ], - streamingMessage: null, - streamingTools: [], - }); - - expect(steps).toEqual([ - expect.objectContaining({ - id: 'tool-image', - label: 'image_generate', - kind: 'tool', - }), - ]); - }); - - it('keeps earlier reply segments in the graph when the last streaming segment is rendered separately', () => { - const steps = deriveTaskSteps({ - messages: [], - streamingMessage: { - role: 'assistant', - content: [ - { type: 'text', text: 'Checked X.' }, - { type: 'text', text: 'Checked X. Checked Snowball.' }, - { type: 'text', text: 'Checked X. Checked Snowball. Here is the summary.' }, - ], - }, - streamingTools: [], - omitLastStreamingMessageSegment: true, - }); - - expect(steps).toEqual([ - expect.objectContaining({ - id: 'stream-message-0', - detail: 'Checked X.', - status: 'completed', - }), - expect.objectContaining({ - id: 'stream-message-1', - detail: 'Checked Snowball.', - status: 'completed', - }), - ]); - }); - - it('folds earlier reply segments into the graph but leaves the final answer for the chat bubble', () => { - const steps = deriveTaskSteps({ - messages: [ - { - role: 'assistant', - id: 'assistant-reply', - content: [ - { type: 'text', text: 'Checked X.' }, - { type: 'text', text: 'Checked X. Checked Snowball.' }, - { type: 'text', text: 'Checked X. Checked Snowball. Here is the summary.' }, - ], - }, - ], - streamingMessage: null, - streamingTools: [], - }); - - expect(steps).toEqual([ - expect.objectContaining({ - id: 'history-message-assistant-reply-0', - detail: 'Checked X.', - status: 'completed', - }), - expect.objectContaining({ - id: 'history-message-assistant-reply-1', - detail: 'Checked Snowball.', - status: 'completed', - }), - ]); - }); - - it('strips folded process narration from the final reply text', () => { - expect(stripProcessMessagePrefix( - 'Checked X. Checked Snowball. Here is the summary.', - ['Checked X.', 'Checked Snowball.'], - )).toBe('Here is the summary.'); - }); - - it('builds a branch for spawned subagents', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - id: 'assistant-2', - content: [ - { - type: 'tool_use', - id: 'spawn-1', - name: 'sessions_spawn', - input: { agentId: 'coder', task: 'inspect repo' }, - }, - { - type: 'tool_use', - id: 'yield-1', - name: 'sessions_yield', - input: { message: 'wait coder finishes' }, - }, - ], - }, - ]; - - const steps = deriveTaskSteps({ - messages, - streamingMessage: null, - streamingTools: [], - }); - - expect(steps).toEqual([ - expect.objectContaining({ - id: 'spawn-1', - label: 'sessions_spawn', - depth: 1, - }), - expect.objectContaining({ - id: 'spawn-1:branch', - label: 'coder run', - depth: 2, - parentId: 'spawn-1', - }), - expect.objectContaining({ - id: 'yield-1', - label: 'sessions_yield', - depth: 3, - parentId: 'spawn-1:branch', - }), - ]); - }); - - it('parses internal subagent completion events from injected user messages', () => { - const info = parseSubagentCompletionInfo({ - role: 'user', - content: [{ - type: 'text', - text: `[Internal task completion event] -source: subagent -session_key: agent:coder:subagent:child-123 -session_id: child-session-id -status: completed successfully`, - }], - } as RawMessage); - - expect(info).toEqual({ - sessionKey: 'agent:coder:subagent:child-123', - sessionId: 'child-session-id', - agentId: 'coder', - }); - }); -}); - -describe('run completion detection', () => { - it('treats delivered image attachments as a final reply after image generation tools', () => { - const messages: RawMessage[] = [ - { - role: 'assistant', - content: [{ - type: 'toolCall', - id: 'tool-image', - name: 'image_generate', - arguments: { prompt: 'wheat' }, - }], - }, - { - role: 'assistant', - content: [{ - type: 'toolCall', - id: 'tool-message', - name: 'message', - arguments: { - action: 'send', - attachments: [{ path: '/tmp/wheat.png' }], - }, - }], - }, - { - role: 'assistant', - content: [{ - type: 'image', - url: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full', - mimeType: 'image/png', - alt: 'wheat.png', - }], - _attachedFiles: [{ - fileName: 'wheat.png', - mimeType: 'image/png', - fileSize: 42, - preview: 'data:image/png;base64,ok', - gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full', - source: 'gateway-media', - }], - }, - ]; - - expect(segmentHasFinalReply(messages)).toBe(true); - expect(findReplyMessageIndex(messages, false)).toBe(2); - }); -}); diff --git a/tests/unit/use-new-chat-action.test.tsx b/tests/unit/use-new-chat-action.test.tsx index ce14697c..d5c756ef 100644 --- a/tests/unit/use-new-chat-action.test.tsx +++ b/tests/unit/use-new-chat-action.test.tsx @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { chatState, navigateMock, settingsState } = vi.hoisted(() => ({ chatState: { - messages: [] as unknown[], currentSessionKey: 'agent:main:main', sessions: [] as Array<{ key: string; workspacePath?: string; createdLocally?: boolean }>, newSession: vi.fn(), @@ -37,7 +36,6 @@ vi.mock('@/stores/settings', () => { describe('useNewChatAction', () => { beforeEach(() => { - chatState.messages = []; chatState.currentSessionKey = 'agent:main:main'; chatState.sessions = []; chatState.newSession.mockReset(); @@ -46,7 +44,7 @@ describe('useNewChatAction', () => { navigateMock.mockReset(); }); - it('starts a fresh local chat even when the legacy message list is empty', async () => { + it('starts a fresh local chat from catalog state alone', async () => { const { useNewChatAction } = await import('@/components/layout/use-new-chat-action'); const { result } = renderHook(() => useNewChatAction()); diff --git a/tests/unit/wechat-login.test.ts b/tests/unit/wechat-login.test.ts index a875a73d..eeca0e32 100644 --- a/tests/unit/wechat-login.test.ts +++ b/tests/unit/wechat-login.test.ts @@ -101,4 +101,43 @@ describe('wechat login utility', () => { ) as string[]; expect(accountIndex).toEqual(['bot-im-bot']); }); + + it('loads the route tag from the running coordinator snapshot', async () => { + const manager = { + getStatus: vi.fn(() => ({ state: 'running' as const })), + rpc: vi.fn(async (method: string) => { + if (method === 'config.get') { + return { + raw: JSON.stringify({ + channels: { + 'openclaw-weixin': { + accounts: { 'bot-im-bot': { routeTag: 'route-live' } }, + }, + }, + }), + hash: 'hash-1', + }; + } + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + qrcode: 'qr-token', + qrcode_img_content: 'https://example.com/qr.png', + }), + }); + vi.stubGlobal('fetch', fetchMock); + const { registerOpenClawConfigCoordinator } = await import('@electron/gateway/config-delivery'); + registerOpenClawConfigCoordinator(manager); + const { startWeChatLoginSession } = await import('@electron/utils/wechat-login'); + + await startWeChatLoginSession({ accountId: 'bot@im.bot' }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + { headers: { SKRouteTag: 'route-live' } }, + ); + }); });