From b780c46be3bca836f44659d4b84030c113c7c7b8 Mon Sep 17 00:00:00 2001 From: ZHUO Xu Date: Thu, 13 Aug 2026 10:18:33 +0800 Subject: [PATCH] fix: preserve ACP lineage in runs recovery across gateway restarts; avoid false gateway disconnect status (#1240) --- .gitignore | 1 + README.ja-JP.md | 2 +- README.md | 2 +- README.ru-RU.md | 4 +- README.zh-CN.md | 2 +- docs/en-US/architecture.md | 4 +- docs/ja-JP/architecture.md | 4 +- docs/ru-RU/architecture.md | 4 +- docs/zh-CN/architecture.md | 4 +- harness/reference/acp-chat.md | 6 +- .../specs/rules/acp-chat-state-and-history.md | 4 +- ...cover-acp-session-after-gateway-restart.md | 87 +++ .../tasks/upgrade-openclaw-2026-7-1-2.md | 4 + patches/openclaw@2026.7.1-2.patch | 695 ++++++++++++++++++ pnpm-lock.yaml | 61 +- pnpm-workspace.yaml | 3 + src/components/web-browser/WebBrowserHost.tsx | 8 + src/pages/Chat/ChatInput.tsx | 3 +- tests/e2e/chat-acp-inline-timeline.spec.ts | 20 +- tests/e2e/chat-workspace-context.spec.ts | 30 + .../openclaw-restart-recovery-patch.test.ts | 240 ++++++ tests/unit/web-browser-host.test.tsx | 15 + 22 files changed, 1153 insertions(+), 50 deletions(-) create mode 100644 harness/specs/tasks/recover-acp-session-after-gateway-restart.md create mode 100644 patches/openclaw@2026.7.1-2.patch create mode 100644 tests/unit/openclaw-restart-recovery-patch.test.ts diff --git a/.gitignore b/.gitignore index ca6474a4..60d79714 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ resources/openclaw-plugins/skillshub/ .opencode .superpowers +.playwright-mcp \ No newline at end of file diff --git a/README.ja-JP.md b/README.ja-JP.md index b59dcef8..a5d2d72c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -152,7 +152,7 @@ ClawXは **Host API統一レイヤーを備えたデュアルプロセスアー - **プロセスモデル**:Electron Mainがウィンドウ、Gateway監視、システム統合、更新を管理します。OpenClaw GatewayはAIオーケストレーション、チャネル、スキル機能を提供し、Rendererはローカルエンドポイントへ直接アクセスしません。 - **設定の配信**:Gateway実行中は `config.get` / `config.set` を使い、停止中または起動中は解決済みJSON5設定を更新します。通常のプロバイダー、Agent、スキル、モデル変更ではプロセスを置き換えず、認証情報は `secrets.reload` でホットリロードされます。ハートビートが4回連続で失敗した場合は、ライフサイクルで保護された復旧を要求します。 -- **ACP Chat**:Chatは [ACP(Agent Client Protocol)](https://agentclientprotocol.com) をMainが所有するstdio bridge経由で使用し、設定リロード後の認証済み履歴リプレイ、ページ移動中のストリーミング、Mainが検証したメディア・添付ファイル・ファイルアクティビティに対応します。 +- **ACP Chat**:Chat UIは [ACP(Agent Client Protocol)](https://agentclientprotocol.com) を介してOpenClawとやり取りし、頻繁に反復されるOpenClawの前に比較的安定したチャットプロトコル面を確保します。ACPはMainが所有するstdio bridge経由で動作し、設定リロード後の認証済み履歴リプレイ、ページ移動中のストリーミング、Mainが検証したメディア・添付ファイル・ファイルアクティビティに対応します。保護されたGateway再起動によって受理済みターンが中断された場合、パッチ済みOpenClawランタイムは復旧runを元のACP promptへ明示的に関連付け、後続のテキストとツールアクティビティを同じメモリ内ターンで継続します。その後の履歴リプレイでも、永続化されたツール境界をネイティブACP updateとして復元します。 - **設計原則**:フロントエンドの単一入口、Mainによるトランスポート管理、再接続・タイムアウト・バックオフによるグレースフルリカバリ、安全なストレージ、CORSセーフな境界を採用しています。 > プロセス図、設定の調整、ACPファイルアクティビティのセマンティクス、Gatewayのトラブルシューティングについては [docs/ja-JP/architecture.md](docs/ja-JP/architecture.md) を参照してください。 diff --git a/README.md b/README.md index 6d1846a6..3c5da4cf 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ ClawX uses a **dual-process architecture with a unified Host API layer**: the Re - **Process model**: Electron Main owns the window, Gateway supervision, system integration, and updates; the OpenClaw Gateway provides AI orchestration, channel, and skill capabilities; the renderer does not access local endpoints directly. - **Configuration delivery**: Main uses `config.get`/`config.set` while the Gateway is running and updates the resolved JSON5 config while it is stopped or starting; ordinary provider, agent, skill, and model changes do not replace the process, credentials are hot-reloaded through `secrets.reload`, and guarded recovery starts after four consecutive heartbeat misses. -- **ACP Chat**: Chat uses [ACP (Agent Client Protocol)](https://agentclientprotocol.com) through a Main-owned stdio bridge, supporting authenticated history replay after config reloads, streaming across navigation, and Main-validated media, attachments, and file activity. +- **ACP Chat**: Chat UI talks to OpenClaw via [ACP (Agent Client Protocol)](https://agentclientprotocol.com), providing a relatively stable chat protocol surface in front of the rapidly iterating OpenClaw. ACP runs through a Main-owned stdio bridge, supporting authenticated history replay after config reloads, streaming across navigation, and Main-validated media, attachments, and file activity. When a guarded Gateway restart interrupts an accepted turn, the patched OpenClaw runtime explicitly links its recovery run to the original ACP prompt so subsequent text and tool activity continue in the same in-memory turn; later history replay restores persisted tool boundaries as native ACP updates. - **Design principles**: One frontend entry point, Main-owned transport, graceful recovery with reconnect/timeout/backoff, secure storage, and CORS-safe boundaries. > For the process diagram, configuration coordination, ACP file activity semantics, and Gateway troubleshooting, see [docs/en-US/architecture.md](docs/en-US/architecture.md). diff --git a/README.ru-RU.md b/README.ru-RU.md index 46480d60..cbd5c376 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -151,8 +151,8 @@ ClawX включает встроенные настройки прокси дл ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**: React Renderer обращается к единой абстракции клиента, а Electron Main управляет выбором протокола, жизненным циклом Gateway и stdio bridge для ACP Chat. - **Модель процессов**: Electron Main управляет окном, наблюдением за Gateway, системной интеграцией и обновлениями; OpenClaw Gateway предоставляет возможности AI-оркестрации, каналов и навыков; Renderer не обращается к локальным эндпоинтам напрямую. -- **Доставка конфигурации**: изменения среды выполнения используют авторитетный снимок `config.set`, поэтому обычные изменения провайдера, агента, навыка и модели не заменяют процесс Gateway; учётные данные обновляются без перезапуска через `secrets.reload`. -- **ACP Chat**: Chat использует [ACP (Agent Client Protocol)](https://agentclientprotocol.com) через stdio bridge под управлением Main, поддерживая аутентифицированное воспроизведение истории после перезагрузки конфигурации, потоковую выдачу при навигации и медиа, вложения и файловые операции, проверенные Main. +- **Доставка конфигурации**: изменения среды выполнения используют авторитетный снимок `config.set`, поэтому обычные изменения провайдера, агента, навыка и модели не заменяют процесс Gateway; учётные данные обновляются без перезапуска через `secrets.reload`, а защищённое восстановление запускается после четырёх последовательных пропусков heartbeat. +- **ACP Chat**: Chat UI взаимодействует с OpenClaw через [ACP (Agent Client Protocol)](https://agentclientprotocol.com), обеспечивая относительно стабильную поверхность чат-протокола поверх часто итерируемого OpenClaw. ACP работает через stdio bridge под управлением Main, поддерживая аутентифицированное воспроизведение истории после перезагрузки конфигурации, потоковую выдачу при навигации, а также медиа, вложения и файловые операции, проверенные Main. Если защищённый перезапуск Gateway прерывает уже принятый ход, исправленная среда OpenClaw явно связывает восстановительный run с исходным ACP prompt, чтобы последующий текст и активность инструментов продолжались в том же ходе в памяти; последующее воспроизведение истории восстанавливает сохранённые границы инструментов как нативные обновления ACP. - **Принципы проектирования**: единая точка входа фронтенда, транспорт под управлением Main, корректное восстановление с переподключением/таймаутом/повтором, безопасное хранение и границы, защищённые от CORS. > Схема процессов, координация конфигурации, семантика файловых операций ACP и устранение неполадок Gateway описаны в [docs/ru-RU/architecture.md](docs/ru-RU/architecture.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 28369c6f..b9a3ee78 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -153,7 +153,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**:React 渲染进程只 - **进程模型**:Electron 主进程负责窗口、网关进程监控、系统集成与自动更新;OpenClaw Gateway 作为独立运行时进程提供 AI 编排、频道和技能能力;渲染层不直接访问本地端点。 - **配置交付**:Gateway 运行时由 Main 使用 `config.get` / `config.set`,停止或启动中则更新解析后的 JSON5 配置;普通 Provider/Agent/Skill/模型修改不会替换进程,凭据通过 `secrets.reload` 热更新;连续 4 次心跳无响应后才会请求受生命周期保护的自动恢复。 -- **ACP Chat**:Chat UI 基于 ACP ([Agent Client Protocol](https://agentclientprotocol.com)) 与 OpenClaw 交互,从而在高速迭代的 OpenClaw 前找到相对稳定的聊天协议面。ACP 走 Main 持有的 stdio bridge,支持配置热重载后的历史回放认证、跨页面持续流式输出,以及由 Main 验证和加载的媒体/附件/文件活动(Changes)展示。 +- **ACP Chat**:Chat UI 基于 ACP ([Agent Client Protocol](https://agentclientprotocol.com)) 与 OpenClaw 交互,从而在高速迭代的 OpenClaw 前找到相对稳定的聊天协议面。ACP 走 Main 持有的 stdio bridge,支持配置热重载后的历史回放认证、跨页面持续流式输出,以及由 Main 验证和加载的媒体/附件/文件活动(Changes)展示。当受保护的 Gateway 重启中断已接收的对话轮次时,补丁后的 OpenClaw 运行时会将恢复 run 显式关联到原 ACP prompt,使后续文本和工具活动继续进入同一个内存轮次;之后的历史回放也会以原生 ACP 更新恢复持久化的工具边界。 - **设计原则**:前端调用单一入口、Main 掌控传输策略、优雅恢复(重连/超时/退避)、安全存储与 CORS 安全。 > 完整架构说明(进程图、配置协调、ACP 文件活动语义与 Gateway 排障)请参阅 [docs/zh-CN/architecture.md](docs/zh-CN/architecture.md)。 diff --git a/docs/en-US/architecture.md b/docs/en-US/architecture.md index 532f9bc1..768ff6ae 100644 --- a/docs/en-US/architecture.md +++ b/docs/en-US/architecture.md @@ -6,7 +6,7 @@ ClawX uses a **dual-process architecture with a unified Host API layer**. The re OpenClaw configuration delivery is also managed by Electron Main. While the Gateway is running, ClawX uses the authoritative snapshot returned by `config.get` as its baseline and commits changes with `config.set`. While the Gateway is stopped or starting, the same coordinator updates the resolved JSON5 configuration file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts are reserved for process-launch environment changes such as proxy settings and explicit user actions. Confirmed process exits and WebSocket closes retain their existing automatic reconnect paths. The first three consecutive WebSocket heartbeat misses remain diagnostic-only so brief pong delays do not interrupt long-running work; a pong or any incoming message resets the count, while a fourth consecutive miss requests guarded automatic Gateway recovery when the lifecycle is in an auto-recoverable running state. After authentication configuration is written to SQLite, ClawX calls OpenClaw's `secrets.reload` so running agents can read new credentials without a process restart. -Chat uses an ACP stdio bridge owned by Electron Main. Main passes the same app-managed Gateway token to this local child through its private process environment, so ACP history replay remains authenticated when the runtime configuration reloads. The renderer receives typed host events and renders an in-memory ACP timeline. The Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration. +Chat uses an ACP stdio bridge owned by Electron Main. Main passes the same app-managed Gateway token to this local child through its private process environment, so ACP history replay remains authenticated when the runtime configuration reloads. If guarded Gateway recovery interrupts an accepted main-session run, the patched OpenClaw runtime starts a distinct recovery run carrying the interrupted run id as explicit lineage. Chat and agent events preserve that lineage; the reconnecting ACP bridge adopts the new run for its pending prompt, resets per-run stream cursors, and subscribes to session-scoped tool events. The renderer remains unaware of Gateway runtime identity and continues to receive typed host events for one in-memory ACP timeline. The Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration. ### ACP Semantic Authority @@ -16,7 +16,7 @@ A bypass is allowed only when upstream ACP has no equivalent. Such a compatibili ### ACP History Authority and Bounded Transcript Supplements -ACP `session/load` replay is the primary authority for Chat history. ClawX does not persist a second ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Some OpenClaw capabilities do not yet have fully corresponding ACP implementations; for example, assistant media may be omitted from ACP and Gateway processing may remove assistant `MEDIA:` directives from the visible live reply. ClawX therefore keeps only bounded, marked, memory-only compatibility supplements: +ACP `session/load` replay is the primary authority for Chat history. ClawX does not persist a second ACP ledger, reduced timeline, replay cache, or reconstructed tool history. If OpenClaw's structured ACP event ledger is unavailable, its ACP adapter reconstructs persisted transcript `toolCall` and `toolResult` records as native tool updates in transcript order, preserving text-tool-text boundaries; ClawX does not infer those records itself. Some OpenClaw capabilities do not yet have fully corresponding ACP implementations; for example, assistant media may be omitted from ACP and Gateway processing may remove assistant `MEDIA:` directives from the visible live reply. ClawX therefore keeps only bounded, marked, memory-only compatibility supplements: - Asynchronous image-generation completions may be restored only when the same session has proven `image_generate` context and the completion evidence is trusted or approved transcript evidence. - General attachments may be recovered from canonical persisted assistant `__openclaw.media` facts or explicit line-leading assistant `MEDIA:` directives. This recovers attachment references and declared metadata, not the surrounding assistant message. diff --git a/docs/ja-JP/architecture.md b/docs/ja-JP/architecture.md index 41377dd7..04702771 100644 --- a/docs/ja-JP/architecture.md +++ b/docs/ja-JP/architecture.md @@ -6,7 +6,7 @@ ClawXは **統合Host APIレイヤーを備えたデュアルプロセスアー OpenClawの設定配信もElectron Mainが管理します。Gateway実行中は`config.get`が返す権威あるスナップショットを基準にし、変更を`config.set`でコミットします。Gatewayが停止中または起動中の場合は、同じコーディネーターが解決済みJSON5設定ファイルを更新しますが、これを理由にGatewayを起動することはありません。そのため、通常のプロバイダー、Agent、チャネル、バインディング、スキル、モデルの変更ではGatewayプロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更と、ユーザーによる明示的な操作に限られます。確認済みのプロセス終了とWebSocket切断では、既存の自動再接続経路が引き続き使用されます。WebSocketハートビートの連続3回までの欠落は診断のみとし、短いpong遅延で長時間実行中の処理を中断しません。pongまたは任意の受信メッセージでカウントをリセットし、4回連続で欠落した場合に、ライフサイクルが自動復旧可能なrunning状態であれば、保護されたGateway自動復旧を要求します。認証設定をSQLiteへ書き込んだ後はOpenClawの`secrets.reload`を呼び出し、実行中のAgentがプロセス再起動なしで新しい認証情報を読み取れるようにします。 -ChatはElectron Mainが所有するACP stdio bridgeを使用します。Mainはアプリが管理するGateway tokenをプライベートなプロセス環境経由でローカルの子プロセスへ渡すため、ランタイム設定の再読み込み後もACP履歴リプレイの認証が維持されます。Rendererは型付きhost eventを受け取り、メモリ上のACP timelineを描画します。Gatewayはproviders、models、skills、workspace、settings、diagnostics、media configurationなどの非Chat機能を引き続き担当します。 +ChatはElectron Mainが所有するACP stdio bridgeを使用します。Mainはアプリが管理するGateway tokenをプライベートなプロセス環境経由でローカルの子プロセスへ渡すため、ランタイム設定の再読み込み後もACP履歴リプレイの認証が維持されます。保護されたGateway復旧が受理済みのメインセッションrunを中断した場合、パッチ済みOpenClawランタイムは別の復旧runを開始し、中断されたrun idを明示的なlineageとして保持します。Chat eventとagent eventはそのlineageを維持し、再接続したACP bridgeはpending promptを新しいrunへ引き継ぎ、run単位のストリームカーソルをリセットしてセッション単位のtool eventを購読します。RendererはGatewayランタイムの識別子を認識せず、型付きhost eventから同じメモリ内ACP timelineを描画し続けます。Gatewayはproviders、models、skills、workspace、settings、diagnostics、media configurationなどの非Chat機能を引き続き担当します。 ### ACPのセマンティック権威 @@ -16,7 +16,7 @@ ACPが提供するすべてのChatの意味とコンテキストでは、`sessio ### ACP履歴の権威と有界なtranscript補足 -ACP `session/load` のリプレイがChat履歴の主要な権威です。ClawXは第二のACP ledger、縮約timeline、リプレイキャッシュ、再構成したツール履歴を永続化しません。OpenClawの一部の機能にはまだ完全に対応するACP実装がありません。たとえば、assistantメディアがACPから省略されたり、Gateway処理によってassistantの`MEDIA:`ディレクティブが表示中のライブ返信から削除されたりする場合があります。そのため、ClawXは有界で印付き、メモリのみの互換性補足だけを保持します。 +ACP `session/load` のリプレイがChat履歴の主要な権威です。ClawXは第二のACP ledger、縮約timeline、リプレイキャッシュ、再構成したツール履歴を永続化しません。OpenClawの構造化ACP event ledgerが利用できない場合、そのACP adapterは永続化済みtranscriptの`toolCall`と`toolResult`を順番どおりにネイティブなtool updateへ再構成し、text-tool-textの境界を維持します。ClawX自身はこれらの記録を推論しません。OpenClawの一部の機能にはまだ完全に対応するACP実装がありません。たとえば、assistantメディアがACPから省略されたり、Gateway処理によってassistantの`MEDIA:`ディレクティブが表示中のライブ返信から削除されたりする場合があります。そのため、ClawXは有界で印付き、メモリのみの互換性補足だけを保持します。 - 非同期の画像生成完了は、同じセッションに確認済みの`image_generate`コンテキストがあり、完了の証拠が信頼できるか、承認済みのtranscript証拠である場合に限り復元できます。 - 一般の添付ファイルは、永続化されたassistantの`__openclaw.media`事実、または行頭にある明示的なassistant `MEDIA:`ディレクティブから復元できます。復元されるのは添付ファイルの参照と宣言されたメタデータだけで、周囲のassistantメッセージは復元しません。 diff --git a/docs/ru-RU/architecture.md b/docs/ru-RU/architecture.md index 1056fb24..5db95bd8 100644 --- a/docs/ru-RU/architecture.md +++ b/docs/ru-RU/architecture.md @@ -6,7 +6,7 @@ ClawX использует **двухпроцессную архитектуру Доставка конфигурации OpenClaw также управляется Electron Main. Когда Gateway запущен, ClawX использует авторитетный снимок из `config.get` как основу и применяет изменения через `config.set`. Когда Gateway остановлен или запускается, тот же координатор обновляет разрешённый JSON5-файл конфигурации, не запуская Gateway из-за этого обновления. Поэтому обычные изменения провайдера, агента, канала, привязки, навыка и модели не заменяют процесс Gateway. Полные перезапуски остаются только для изменений среды запуска процесса, например прокси, и явных действий пользователя. Подтверждённые завершения процесса и закрытия WebSocket используют существующие пути автоматического переподключения. Первые три последовательных пропуска WebSocket heartbeat являются только диагностикой, поэтому краткая задержка pong не прерывает долгую операцию; pong или любое входящее сообщение сбрасывает счётчик, а при четвёртом последовательном пропуске запрашивается защищённое автоматическое восстановление Gateway, если его жизненный цикл находится в состоянии running с разрешённым автовосстановлением. После записи конфигурации аутентификации в SQLite ClawX вызывает `secrets.reload` OpenClaw, чтобы работающие агенты получили новые учётные данные без перезапуска процесса. -Chat использует ACP stdio bridge, принадлежащий Electron Main. Main передаёт тому же локальному дочернему процессу управляемый приложением Gateway token через приватное окружение процесса, поэтому после перезагрузки конфигурации среды выполнения воспроизведение истории ACP остаётся аутентифицированным. Renderer получает типизированные host events и отображает находящуюся в памяти ACP timeline. Gateway продолжает отвечать за возможности вне Chat: providers, models, skills, workspace, settings, diagnostics и media configuration. +Chat использует ACP stdio bridge, принадлежащий Electron Main. Main передаёт тому же локальному дочернему процессу управляемый приложением Gateway token через приватное окружение процесса, поэтому после перезагрузки конфигурации среды выполнения воспроизведение истории ACP остаётся аутентифицированным. Если защищённое восстановление Gateway прерывает принятый run основной сессии, исправленная среда OpenClaw запускает отдельный восстановительный run с явной ссылкой на id прерванного run. События Chat и agent сохраняют эту связь; переподключившийся ACP bridge принимает новый run для ожидающего prompt, сбрасывает потоковые курсоры run и подписывается на события инструментов сессии. Renderer не использует идентификатор среды Gateway и продолжает строить одну ACP timeline в памяти из типизированных host events. Gateway продолжает отвечать за возможности вне Chat: providers, models, skills, workspace, settings, diagnostics и media configuration. ### Семантический авторитет ACP @@ -16,7 +16,7 @@ ACP является предпочтительным семантическим ### Авторитет истории ACP и ограниченные дополнения из transcript -Воспроизведение ACP `session/load` является главным источником истории Chat. ClawX не сохраняет второй ACP ledger, сокращённую timeline, кэш воспроизведения или восстановленную историю инструментов. Некоторые возможности OpenClaw пока не имеют полного соответствия в ACP. Например, media assistant может отсутствовать в ACP, а обработка Gateway может удалять директивы assistant `MEDIA:` из видимого потокового ответа. Поэтому ClawX хранит только ограниченные, помеченные дополнения совместимости в памяти: +Воспроизведение ACP `session/load` является главным источником истории Chat. ClawX не сохраняет второй ACP ledger, сокращённую timeline, кэш воспроизведения или восстановленную историю инструментов. Если структурированный ACP event ledger OpenClaw недоступен, его ACP adapter преобразует сохранённые записи transcript `toolCall` и `toolResult` в нативные обновления инструментов в исходном порядке, сохраняя границы text-tool-text; сам ClawX эти записи не выводит. Некоторые возможности OpenClaw пока не имеют полного соответствия в ACP. Например, media assistant может отсутствовать в ACP, а обработка Gateway может удалять директивы assistant `MEDIA:` из видимого потокового ответа. Поэтому ClawX хранит только ограниченные, помеченные дополнения совместимости в памяти: - Асинхронное завершение генерации изображения можно восстановить только при наличии подтверждённого контекста `image_generate` в той же сессии и доверенного либо разрешённого transcript-доказательства. - Обычные вложения можно восстановить из канонических сохранённых фактов assistant `__openclaw.media` или явных директив assistant `MEDIA:` в начале строки. Восстанавливаются только ссылки на вложения и объявленные метаданные, но не окружающее сообщение assistant. diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index dd93e5e2..b10b65ce 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -6,7 +6,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化和用户显式操作。已确认的进程退出与 WebSocket 关闭继续使用现有的自动重连路径。连续前 3 次 WebSocket 心跳无响应只更新诊断,不会因短暂的 pong 延迟中断长时间运行的任务;收到 pong 或任意消息会重置计数,连续第 4 次无响应时,只有在生命周期处于可自动恢复的 running 状态时,才会请求受保护的 Gateway 自动恢复。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。 -Chat 使用由 Electron Main 持有的 ACP stdio bridge。Main 通过私有进程环境把同一份应用管理的 Gateway token 传给本地子进程,因此运行时配置重载后 ACP 历史回放仍能完成认证。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。 +Chat 使用由 Electron Main 持有的 ACP stdio bridge。Main 通过私有进程环境把同一份应用管理的 Gateway token 传给本地子进程,因此运行时配置重载后 ACP 历史回放仍能完成认证。如果受保护的 Gateway 恢复中断了已接收的主会话 run,补丁后的 OpenClaw 运行时会启动独立的恢复 run,并显式携带被中断 run id 作为 lineage。Chat 和 agent events 会保留该 lineage;重连后的 ACP bridge 据此将 pending prompt 接续到新 run,重置该 run 的流式游标,并订阅会话级 tool events。Renderer 不感知 Gateway 运行实例身份,仍通过类型化 host events 渲染同一个内存 ACP timeline。Gateway 继续负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。 ### ACP 语义权威 @@ -16,7 +16,7 @@ Chat 使用由 Electron Main 持有的 ACP stdio bridge。Main 通过私有进 ### ACP 历史权威与有界 transcript 补充 -ACP `session/load` 回放是 Chat 历史的首要事实来源。ClawX 不会持久化第二套 ACP ledger、精简 timeline、回放缓存或重建的工具历史。OpenClaw 的部分能力目前还没有完全对应的 ACP 实现;例如,assistant 媒体可能不会出现在 ACP 中,Gateway 处理也可能从可见的实时回复中移除 assistant `MEDIA:` 指令。因此,ClawX 只保留有界、带标记、仅存于内存的兼容性补充路径: +ACP `session/load` 回放是 Chat 历史的首要事实来源。ClawX 不会持久化第二套 ACP ledger、精简 timeline、回放缓存或重建的工具历史。当 OpenClaw 的结构化 ACP event ledger 不可用时,其 ACP adapter 会按 transcript 顺序把持久化的 `toolCall` 和 `toolResult` 记录重建为原生工具更新,并保留 text-tool-text 边界;ClawX 本身不会推断这些记录。OpenClaw 的部分能力目前还没有完全对应的 ACP 实现;例如,assistant 媒体可能不会出现在 ACP 中,Gateway 处理也可能从可见的实时回复中移除 assistant `MEDIA:` 指令。因此,ClawX 只保留有界、带标记、仅存于内存的兼容性补充路径: - 只有在同一 session 中存在已确认的 `image_generate` 上下文,且完成证据可信或来自获准 transcript 证据时,才可以恢复异步图像生成结果。 - 普通附件可以从持久化的 assistant `__openclaw.media` 规范事实或明确的行首 assistant `MEDIA:` 指令中恢复。这只恢复附件引用和声明的元数据,不恢复周围的 assistant 消息。 diff --git a/harness/reference/acp-chat.md b/harness/reference/acp-chat.md index e1ea2c37..c2d7c479 100644 --- a/harness/reference/acp-chat.md +++ b/harness/reference/acp-chat.md @@ -6,7 +6,7 @@ Related scenario: `acp-chat-experience` Related rules: `acp-chat-state-and-history`, `attachment-access-safety`, `renderer-main-boundary` -Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session` +Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session`, `recover-acp-session-after-gateway-restart` ## Ownership @@ -33,11 +33,13 @@ Renderer-visible session identity is the OpenClaw Gateway session key. Main may While `session/prompt` is pending, Main retains a bounded session-id routing context and Renderer retains that prompt's reduced timeline and original client-observed turn start in memory. This lets another page or conversation be viewed without dropping the original stream or resetting elapsed time. Returning to the live conversation reactivates its existing ACP context and restores the memory snapshot without calling `session/load`; updates received during the handoff are still generation-filtered. Prompt settlement releases both live contexts, after which returning uses ordinary ACP replay plus bounded timing metadata. This is live operation state, not a second history ledger, and it is never persisted. +When guarded Gateway recovery interrupts an accepted main-session run, patched OpenClaw starts a distinct recovery run and marks its Chat and agent events with the interrupted run id as `resumedFromRunId`. The reconnecting ACP bridge keeps the original prompt pending for a bounded 60-second total recovery window, adopts only that explicitly linked recovery run, resets per-run text and tool state, and rebinds cancellation to the new run id. The initial disconnect check still occurs after 5 seconds: prompts whose send was never acknowledged reject then, while acknowledged prompts receive the remaining 55 seconds for Gateway startup backoff and recovery dispatch. ACP also subscribes to session tool events after reconnect, and Gateway mirrors visible recovery tools to that exact session subscription with lineage intact, so recovered tool cards preserve the surrounding text boundaries. Renderer does not reload the session or use Gateway runtime identity for this flow; normal ACP generation, session, and workspace guards remain authoritative. + `messageId` and `toolCallId` are opaque identities within one loaded timeline. They are not durable UI identities across loads. Timeline sequence values and DOM anchors are also local to the active snapshot. ## History Authority -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. +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 restores recorded tools and file activity. When that ledger is unavailable, OpenClaw's ACP adapter maps persisted transcript `toolCall` and `toolResult` records to native ACP tool updates in transcript order, preserving assistant text segments on either side; this is upstream ACP replay, not a ClawX transcript supplement or inference path. 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. diff --git a/harness/specs/rules/acp-chat-state-and-history.md b/harness/specs/rules/acp-chat-state-and-history.md index d38d725d..624f07e2 100644 --- a/harness/specs/rules/acp-chat-state-and-history.md +++ b/harness/specs/rules/acp-chat-state-and-history.md @@ -8,10 +8,10 @@ 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. The local ACP child receives the authoritative `gatewayToken` from Electron store through `OPENCLAW_GATEWAY_TOKEN`; it must not rely on a separately resolved OpenClaw config credential or expose the token through CLI arguments or Renderer state. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while each ordinary live prompt update continues through host events and is applied immediately without a Renderer batching timer. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history. +Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. The local ACP child receives the authoritative `gatewayToken` from Electron store through `OPENCLAW_GATEWAY_TOKEN`; it must not rely on a separately resolved OpenClaw config credential or expose the token through CLI arguments or Renderer state. 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. Gateway restart recovery must use explicit source-run lineage from trusted OpenClaw recovery provenance; ACP may adopt a new run only when `resumedFromRunId` matches its pending prompt, and Renderer must not reload a session based on Gateway runtime identity. An acknowledged prompt may remain pending for at most 60 seconds after disconnect so Gateway startup backoff and restart recovery can complete, while an unacknowledged send retains the 5-second deadline. 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 is the preferred authority for every Chat semantic it exposes, including session routing, workspace and execution `cwd`, prompt/timeline state, and standard resource or attachment semantics. If ACP provides the value or event, implementations must not replace it with Gateway snapshots, transcript inference, local configuration, or a parallel projection. A bypass is permitted only when upstream ACP has no equivalent; it must be narrow, bounded, session- and generation-scoped, and documented with its rationale, source, limits, reconciliation behavior, and removal condition in a relevant Harness reference or rule. -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. +ACP replay is the primary history authority. OpenClaw's native ACP adapter may reconstruct persisted transcript `toolCall` and `toolResult` records as standard `tool_call` and `tool_call_update` events when its structured event ledger is unavailable; ClawX consumes those events normally and does not perform that reconstruction itself. The only approved ClawX transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history. Historical transcript reads are limited to the newest `1000` message records. A successful live prompt reads content immediately and retries exactly once after `1500 ms`. General attachment and timing alignment treat history as a suffix and match the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; they must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live content alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline or timing annotations. diff --git a/harness/specs/tasks/recover-acp-session-after-gateway-restart.md b/harness/specs/tasks/recover-acp-session-after-gateway-restart.md new file mode 100644 index 00000000..25f85578 --- /dev/null +++ b/harness/specs/tasks/recover-acp-session-after-gateway-restart.md @@ -0,0 +1,87 @@ +--- +id: recover-acp-session-after-gateway-restart +title: Continue an ACP prompt through Gateway restart recovery +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Preserve an accepted ACP prompt while OpenClaw replaces an interrupted run with an explicitly linked restart-recovery run. +touchedAreas: + - .gitignore + - README.md + - README.zh-CN.md + - README.ja-JP.md + - README.ru-RU.md + - docs/en-US/architecture.md + - docs/zh-CN/architecture.md + - docs/ja-JP/architecture.md + - docs/ru-RU/architecture.md + - harness/reference/acp-chat.md + - harness/specs/rules/acp-chat-state-and-history.md + - harness/specs/tasks/recover-acp-session-after-gateway-restart.md + - pnpm-workspace.yaml + - pnpm-lock.yaml + - patches/openclaw@2026.7.1-2.patch + - tests/unit/openclaw-restart-recovery-patch.test.ts + - tests/e2e/chat-acp-inline-timeline.spec.ts +expectedUserBehavior: + - An accepted ACP prompt stays pending for up to 60 seconds while the Gateway reconnects and OpenClaw starts restart recovery; a send that was never acknowledged retains the 5-second disconnect deadline. + - Events from the explicitly linked recovery run continue the original in-memory turn and settle its original prompt. + - Recovered text, tool activity, approvals, cancellation, and terminal state use the new run id without a Renderer session reload; tool calls keep text segments on either side distinct. + - After ClawX restarts, ACP `session/load` restores persisted transcript tool calls and results as native tool updates in their original order between assistant text segments. +requiredProfiles: + - fast + - comms +requiredRules: + - renderer-main-boundary + - backend-communication-boundary + - gateway-readiness-policy + - acp-chat-state-and-history + - comms-regression + - docs-sync +requiredTests: + - pnpm exec vitest run tests/unit/openclaw-restart-recovery-patch.test.ts + - pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts --project=parallel --grep "renders ledger-style replayed ACP tool events" + - pnpm run typecheck + - pnpm run comms:replay + - pnpm run comms:compare +acceptance: + - The unique interrupted run id is propagated only by trusted main-session restart recovery and never inferred from a session key. + - Chat and agent events expose `resumedFromRunId`, and ACP adopts the new run only when it matches the pending prompt. + - Adoption clears the stale disconnect deadline, resets per-run stream state, rebinds cancellation, and retains tool and approval delivery after reconnect. + - Visible recovery tool events reach the exact session-message subscription with `resumedFromRunId`, even when no global session-event subscription exists. + - Reconnect reconciliation waits for the exact session-message subscription request to settle, so recovery dispatch cannot race ahead of tool-event registration. + - OpenClaw's ACP transcript fallback maps persisted assistant `toolCall` blocks and `toolResult` messages to `tool_call` and `tool_call_update` rather than flattening adjacent assistant text. + - String-valued and structured transcript tool results retain visible output in the replayed tool card. + - The initial 5-second disconnect check extends only acknowledged prompts to a bounded 60-second total recovery window; unacknowledged prompts still reject after 5 seconds. + - Renderer remains unchanged and ACP replay remains the source of truth for persisted Chat history. +docs: + required: true +--- + +## Original Problem + +If the Gateway restarted while the AI was replying, ClawX could reconnect to the replacement Gateway process but the active ACP conversation did not resume. OpenClaw could start a restart-recovery run and continue producing output, while ClawX remained frozen at the last text received before the disconnect. The original `session/prompt` eventually failed or stayed disconnected from the replacement run because ACP still identified the turn by the interrupted run id and had no trusted lineage proving that the new run continued it. + +The failure was not limited to the live UI. Even when OpenClaw completed and persisted the recovered response, restarting ClawX and selecting the same conversation could still replay only the timeline prefix that existed before the disconnect. The ACP event history associated with the original prompt had not received the replacement run's events, and transcript fallback did not preserve the complete native timeline: it projected text and thinking while dropping persisted `toolCall` and `toolResult` records. Missing tool events also removed the boundaries between assistant text segments around those calls. + +The broken flow therefore had two related symptoms: + +- Live recovery: Gateway connectivity returned, but the replacement run's text, tools, approvals, cancellation target, and terminal state did not settle the original in-memory ACP prompt. +- Reload recovery: after restarting ClawX, `session/load` could reproduce the stale pre-disconnect view or an incomplete flattened response instead of the recovered conversation that OpenClaw had persisted. + +## Fix + +`patches/openclaw@2026.7.1-2.patch` is the ClawX-local backport applied to the pinned `openclaw@2026.7.1-2` runtime. It restores one explicit recovery chain from the interrupted run through live ACP delivery and persisted replay: + +- Persist the active lifecycle run id before a restart and allow only trusted `main_session_restart_recovery` provenance to pass it into a distinct replacement run as `internalRestartRecoverySourceRunId`. +- Project that source id as `resumedFromRunId` on Chat, agent, tool, and approval events. ACP adopts a replacement run only when this value exactly matches its pending prompt; it never infers lineage from a shared session key. +- Keep an acknowledged prompt pending for a bounded 60-second total recovery window while retaining the original 5-second deadline for a send that was never acknowledged. Adoption clears the stale disconnect deadline, resets per-run text, thought, and tool state, rebinds cancellation, and lets the replacement terminal event settle the original prompt. +- Re-register the exact `sessions.messages.subscribe` subscription before reconnect reconciliation can dispatch recovery work. Gateway mirrors visible recovery tool events to that exact subscriber, includes `resumedFromRunId`, and deduplicates clients that already receive the run-scoped event. +- When a complete structured ACP ledger is unavailable, map persisted assistant `toolCall` blocks and `toolResult` messages to native `tool_call` and `tool_call_update` updates in transcript order. Both structured and string-valued results retain visible output, and a text-tool-text sequence reloads as two distinct assistant text segments around the tool card. + +ClawX Renderer remains unchanged. It continues to reduce standard ACP updates into one in-memory timeline; the repair is in OpenClaw's recovery lineage, Gateway event projection, ACP prompt reconciliation, and ACP replay fallback. + +The same source-level fixes will be submitted to the OpenClaw upstream repository as a pull request. This local generated-dist patch is a temporary compatibility measure: after the upstream PR is merged and ClawX upgrades to an OpenClaw release containing the fixes, `patches/openclaw@2026.7.1-2.patch` should be removed rather than carried forward to a newer generated bundle. + +## Scope + +The dependency patch backports current OpenClaw run-lineage, recovered tool delivery, and native ACP transcript replay behavior to the pinned `openclaw@2026.7.1-2` runtime. It patches generated runtime chunks and declarations, so every OpenClaw version change must regenerate and review the patch rather than carrying it forward by filename. diff --git a/harness/specs/tasks/upgrade-openclaw-2026-7-1-2.md b/harness/specs/tasks/upgrade-openclaw-2026-7-1-2.md index e1b74d19..288033b4 100644 --- a/harness/specs/tasks/upgrade-openclaw-2026-7-1-2.md +++ b/harness/specs/tasks/upgrade-openclaw-2026-7-1-2.md @@ -7,12 +7,15 @@ intent: Apply the OpenClaw 2026.7.1 correction releases without regressing ClawX touchedAreas: - package.json - pnpm-lock.yaml + - pnpm-workspace.yaml + - patches/openclaw@2026.7.1-2.patch - electron/gateway/config-sync.ts - electron/gateway/manager.ts - electron/utils/openclaw-upgrade-snapshot.ts - tests/unit/gateway-ready-fallback.test.ts - tests/unit/openclaw-bundle-config.test.ts - tests/unit/openclaw-upgrade-snapshot.test.ts + - tests/unit/openclaw-restart-recovery-patch.test.ts - harness/reference/openclaw-config-delivery.md - harness/specs/tasks/upgrade-openclaw-2026-7-1.md - harness/specs/tasks/upgrade-openclaw-2026-7-1-2.md @@ -38,6 +41,7 @@ requiredTests: - tests/unit/openclaw-auth.test.ts - tests/unit/acp-chat-service.test.ts - tests/unit/gateway-startup-orchestrator.test.ts + - tests/unit/openclaw-restart-recovery-patch.test.ts acceptance: - The OpenClaw runtime is pinned to 2026.7.1-2 and resolves @openclaw/ai 2026.7.1-2. - External channel plugin package versions and ClawX's supported channel catalog remain unchanged because the correction release does not change channel APIs or manifests. diff --git a/patches/openclaw@2026.7.1-2.patch b/patches/openclaw@2026.7.1-2.patch new file mode 100644 index 00000000..ed5fcf92 --- /dev/null +++ b/patches/openclaw@2026.7.1-2.patch @@ -0,0 +1,695 @@ +diff --git a/dist/acp-cli-BXc5GttU.js b/dist/acp-cli-BXc5GttU.js +index 6c7de7d60ac87a733632c36a744ada0e4e48d077..62797972ce78076c545d712809d99f4a13f8e0ee 100644 +--- a/dist/acp-cli-BXc5GttU.js ++++ b/dist/acp-cli-BXc5GttU.js +@@ -1622,8 +1622,25 @@ function buildSessionUsageSnapshot(row) { + } + //#endregion + //#region src/acp/translator.replay.ts ++function extractToolResultReplay(message) { ++ const toolCallId = normalizeOptionalString(message.toolCallId); ++ if (!toolCallId) return []; ++ const rawOutput = { ++ content: message.content, ++ ...message.details === void 0 ? {} : { details: message.details } ++ }; ++ return [{ ++ sessionUpdate: "tool_call_update", ++ toolCallId, ++ status: message.isError === true ? "failed" : "completed", ++ rawOutput, ++ content: extractToolCallContent(message.content) ?? extractToolCallContent(rawOutput), ++ locations: extractToolCallLocations(rawOutput) ++ }]; ++} + function extractReplayChunks(message) { + const role = typeof message.role === "string" ? message.role : ""; ++ if (role === "toolResult") return extractToolResultReplay(message); + if (role !== "user" && role !== "assistant") return []; + if (typeof message.content === "string") return message.content.length > 0 ? [{ + sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk", +@@ -1641,6 +1658,22 @@ function extractReplayChunks(message) { + }); + continue; + } ++ if (role === "assistant" && typedBlock.type === "toolCall") { ++ const toolCallId = normalizeOptionalString(typedBlock.id); ++ const name = normalizeOptionalString(typedBlock.name); ++ if (!toolCallId) continue; ++ const args = asOptionalRecord(typedBlock.arguments); ++ replayChunks.push({ ++ sessionUpdate: "tool_call", ++ toolCallId, ++ title: formatToolTitle(name, args), ++ status: "in_progress", ++ rawInput: args, ++ kind: inferToolKind(name), ++ locations: extractToolCallLocations(args) ++ }); ++ continue; ++ } + if (role === "assistant" && typedBlock.type === "thinking" && typeof typedBlock.thinking === "string" && typedBlock.thinking) replayChunks.push({ + sessionUpdate: "agent_thought_chunk", + text: typedBlock.thinking +@@ -1849,6 +1882,7 @@ const ACP_AGENT_INFO = { + const MAX_PROMPT_BYTES = 2 * 1024 * 1024; + const ACP_LOAD_SESSION_REPLAY_LIMIT = 1e6; + const ACP_GATEWAY_DISCONNECT_GRACE_MS = 5e3; ++const ACP_GATEWAY_ACCEPTED_PROMPT_RECOVERY_GRACE_MS = 6e4; + function normalizedChatSendAckStatus(status) { + return typeof status === "string" ? status.trim().toLowerCase() : ""; + } +@@ -1959,10 +1993,13 @@ var AcpGatewayAgent = class { + } + handleGatewayReconnect() { + this.log("gateway reconnected"); ++ const subscriptionReady = Promise.all([...this.pendingPrompts.values()].map((pending) => this.gateway.request("sessions.messages.subscribe", { key: pending.sessionKey }).catch((err) => { ++ this.log(`session message subscription failed for ${pending.sessionKey}: ${String(err)}`); ++ }))); + const disconnectContext = this.activeDisconnectContext; + this.activeDisconnectContext = null; +- if (!disconnectContext) return; +- this.reconcilePendingPrompts(disconnectContext.generation, false); ++ if (!disconnectContext) return subscriptionReady; ++ return subscriptionReady.then(() => this.reconcilePendingPrompts(disconnectContext.generation, false)); + } + handleGatewayDisconnect(reason) { + this.log(`gateway disconnected: ${reason}`); +@@ -1985,7 +2022,7 @@ var AcpGatewayAgent = class { + this.handleExecApprovalRequestEvent(evt); + return; + } +- if (evt.event === "agent") await this.handleAgentEvent(evt); ++ if (evt.event === "agent" || evt.event === "session.tool") await this.handleAgentEvent(evt); + } + async initialize(params) { + this.clientCapabilities = normalizeClientCapabilities(params.clientCapabilities); +@@ -2299,7 +2336,8 @@ var AcpGatewayAgent = class { + }; + sendWithProvenanceFallback().catch((err) => { + const promptKey = this.pendingPromptKey(params.sessionId, runId); +- if (isGatewayCloseError(err) && (this.getPendingPrompt(params.sessionId, runId) || this.settlingPromptKeys.has(promptKey))) return; ++ const pending = this.pendingPrompts.get(params.sessionId); ++ if (isGatewayCloseError(err) && (this.getPendingPrompt(params.sessionId, runId) || pending?.resumedRunIds?.has(runId) || this.settlingPromptKeys.has(promptKey))) return; + this.clearApprovalRelaysForPrompt(params.sessionId, runId, { denyActive: true }); + this.pendingPrompts.delete(params.sessionId); + this.sessionStore.clearActiveRun(params.sessionId); +@@ -2336,6 +2374,7 @@ var AcpGatewayAgent = class { + const data = payload.data; + const sessionKey = payload.sessionKey; + if (!stream || !data || !sessionKey) return; ++ const pending = this.findPendingBySessionKey(sessionKey, runId, payload.resumedFromRunId); + if (stream === "approval") { + await this.handleApprovalEvent({ + sessionKey, +@@ -2349,7 +2388,6 @@ var AcpGatewayAgent = class { + const name = data.name; + const toolCallId = data.toolCallId; + if (!toolCallId) return; +- const pending = this.findPendingBySessionKey(sessionKey, runId); + if (!pending) return; + if (phase === "start") { + if (!pending.toolCalls) pending.toolCalls = /* @__PURE__ */ new Map(); +@@ -2442,13 +2480,15 @@ var AcpGatewayAgent = class { + if (!sessionKey) return; + this.startApprovalRelay({ + sessionKey, ++ runId: normalizeOptionalString(request?.runId), ++ resumedFromRunId: normalizeOptionalString(request?.resumedFromRunId), + approvalEvent + }); + } + startApprovalRelay(params) { + const approvalEvent = params.approvalEvent; + if (this.approvalRelays.has(approvalEvent.approvalId)) return; +- const pending = params.runId ? this.findPendingBySessionKey(params.sessionKey, params.runId) : this.findUniquePendingBySessionKey(params.sessionKey); ++ const pending = params.runId ? this.findPendingBySessionKey(params.sessionKey, params.runId, params.resumedFromRunId) : this.findUniquePendingBySessionKey(params.sessionKey); + if (!pending) return; + const relay = { + approvalId: approvalEvent.approvalId, +@@ -2526,7 +2566,7 @@ var AcpGatewayAgent = class { + const runId = payload.runId; + const messageData = payload.message; + if (!sessionKey || !state) return; +- const pending = this.findPendingBySessionKey(sessionKey, runId); ++ const pending = this.findPendingBySessionKey(sessionKey, runId, payload.resumedFromRunId); + if (!pending) return; + if (messageData && (state === "delta" || state === "final")) { + await this.handleDeltaEvent(pending.sessionId, messageData); +@@ -2619,18 +2659,55 @@ var AcpGatewayAgent = class { + this.settlingPromptKeys.delete(promptKey); + } + } +- findPendingBySessionKey(sessionKey, runId) { ++ findPendingBySessionKey(sessionKey, runId, resumedFromRunId) { + for (const pending of this.pendingPrompts.values()) { + if (pending.sessionKey !== sessionKey) continue; + if (runId && pending.idempotencyKey !== runId) continue; + return pending; + } ++ if (runId && resumedFromRunId) for (const pending of this.pendingPrompts.values()) { ++ if (pending.idempotencyKey !== resumedFromRunId) continue; ++ this.reconcilePendingSessionKey(pending, sessionKey); ++ this.adoptResumedRun(pending, runId); ++ return pending; ++ } + if (runId) for (const pending of this.pendingPrompts.values()) { + if (pending.idempotencyKey !== runId) continue; + this.reconcilePendingSessionKey(pending, sessionKey); + return pending; + } + } ++ adoptResumedRun(pending, runId) { ++ const previousRunId = pending.idempotencyKey; ++ this.log(`prompt run resumed: ${previousRunId} -> ${runId}`); ++ this.clearApprovalRelaysForPrompt(pending.sessionId, previousRunId, { denyActive: true }); ++ pending.resumedRunIds ??= /* @__PURE__ */ new Set(); ++ pending.resumedRunIds.add(previousRunId); ++ pending.idempotencyKey = runId; ++ pending.sendAccepted = true; ++ pending.disconnectContext = void 0; ++ for (const [toolCallId, toolCall] of pending.toolCalls ?? []) this.sessionUpdates.emit({ ++ sessionId: pending.sessionId, ++ sessionKey: pending.sessionKey, ++ ...pending.ledgerSessionId ? { ledgerSessionId: pending.ledgerSessionId } : {}, ++ runId: previousRunId, ++ record: true, ++ waitForDelivery: false, ++ update: { ++ sessionUpdate: "tool_call_update", ++ toolCallId, ++ status: "failed", ++ locations: toolCall.locations ++ } ++ }); ++ pending.sentTextLength = 0; ++ pending.sentText = void 0; ++ pending.sentThoughtLength = 0; ++ pending.sentThought = void 0; ++ pending.toolCalls = void 0; ++ const session = this.sessionStore.getSession(pending.sessionId); ++ if (session?.abortController) this.sessionStore.setActiveRun(pending.sessionId, runId, session.abortController); ++ } + findUniquePendingBySessionKey(sessionKey) { + let match; + for (const pending of this.pendingPrompts.values()) { +@@ -2652,12 +2729,12 @@ var AcpGatewayAgent = class { + clearTimeout(this.disconnectTimer); + this.disconnectTimer = null; + } +- armDisconnectTimer(disconnectContext) { ++ armDisconnectTimer(disconnectContext, deadline = "initial") { + this.clearDisconnectTimer(); + this.disconnectTimer = setTimeout(() => { + this.disconnectTimer = null; +- this.reconcilePendingPrompts(disconnectContext.generation, true); +- }, ACP_GATEWAY_DISCONNECT_GRACE_MS); ++ this.reconcilePendingPrompts(disconnectContext.generation, deadline); ++ }, deadline === "initial" ? ACP_GATEWAY_DISCONNECT_GRACE_MS : ACP_GATEWAY_ACCEPTED_PROMPT_RECOVERY_GRACE_MS - ACP_GATEWAY_DISCONNECT_GRACE_MS); + this.disconnectTimer.unref?.(); + } + rejectPendingPrompt(pending, error) { +@@ -2668,14 +2745,10 @@ var AcpGatewayAgent = class { + if (this.pendingPrompts.size === 0) this.clearDisconnectTimer(); + pending.reject(error); + } +- clearPendingDisconnectState(pending, disconnectContext) { +- if (pending.disconnectContext !== disconnectContext) return; +- pending.disconnectContext = void 0; ++ shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext, deadline) { ++ return pending.disconnectContext === disconnectContext && (!pending.sendAccepted || deadline === "accepted-recovery"); + } +- shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext) { +- return pending.disconnectContext === disconnectContext && (!pending.sendAccepted || this.activeDisconnectContext?.generation === disconnectContext.generation); +- } +- async reconcilePendingPrompts(observedDisconnectGeneration, deadlineExpired) { ++ async reconcilePendingPrompts(observedDisconnectGeneration, deadline) { + if (this.pendingPrompts.size === 0) { + if (this.disconnectGeneration === observedDisconnectGeneration) this.clearDisconnectTimer(); + return; +@@ -2685,32 +2758,35 @@ var AcpGatewayAgent = class { + for (const [sessionId, pending] of pendingEntries) { + if (this.pendingPrompts.get(sessionId) !== pending) continue; + if (pending.disconnectContext?.generation !== observedDisconnectGeneration) continue; +- if (await this.reconcilePendingPrompt(sessionId, pending, deadlineExpired)) keepDisconnectTimer = true; ++ if (await this.reconcilePendingPrompt(sessionId, pending, deadline)) keepDisconnectTimer = true; + } +- if (!keepDisconnectTimer && this.disconnectGeneration === observedDisconnectGeneration) this.clearDisconnectTimer(); ++ if (keepDisconnectTimer && deadline === "initial" && this.disconnectGeneration === observedDisconnectGeneration) { ++ const disconnectContext = pendingEntries.map(([, pending]) => pending.disconnectContext).find((context) => context?.generation === observedDisconnectGeneration); ++ if (disconnectContext) this.armDisconnectTimer(disconnectContext, "accepted-recovery"); ++ } else if (!keepDisconnectTimer && this.disconnectGeneration === observedDisconnectGeneration) this.clearDisconnectTimer(); + } +- async reconcilePendingPrompt(sessionId, pending, deadlineExpired) { ++ async reconcilePendingPrompt(sessionId, pending, deadline) { + const disconnectContext = pending.disconnectContext; + if (!disconnectContext) return false; ++ const waitedRunId = pending.idempotencyKey; + let result; + try { + result = await this.gateway.request("agent.wait", { +- runId: pending.idempotencyKey, ++ runId: waitedRunId, + timeoutMs: 0 + }, { timeoutMs: null }); + } catch (err) { +- this.log(`agent.wait reconcile failed for ${pending.idempotencyKey}: ${String(err)}`); +- if (deadlineExpired) { +- if (this.shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext)) { ++ this.log(`agent.wait reconcile failed for ${waitedRunId}: ${String(err)}`); ++ if (deadline) { ++ if (this.shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext, deadline)) { + this.rejectPendingPrompt(pending, /* @__PURE__ */ new Error(`Gateway disconnected: ${disconnectContext.reason}`)); + return false; + } +- this.clearPendingDisconnectState(pending, disconnectContext); +- return false; ++ return true; + } + return true; + } +- const currentPending = this.getPendingPrompt(sessionId, pending.idempotencyKey); ++ const currentPending = this.getPendingPrompt(sessionId, waitedRunId); + if (!currentPending) return false; + if (result?.status === "ok") { + await this.finishPrompt(sessionId, currentPending, "end_turn"); +@@ -2720,15 +2796,14 @@ var AcpGatewayAgent = class { + this.finishPrompt(sessionId, currentPending, "end_turn"); + return false; + } +- if (deadlineExpired) { +- if (this.shouldRejectPendingAtDisconnectDeadline(currentPending, disconnectContext)) { ++ if (deadline) { ++ if (this.shouldRejectPendingAtDisconnectDeadline(currentPending, disconnectContext, deadline)) { + const currentDisconnectContext = currentPending.disconnectContext; + if (!currentDisconnectContext) return false; + this.rejectPendingPrompt(currentPending, /* @__PURE__ */ new Error(`Gateway disconnected: ${currentDisconnectContext.reason}`)); + return false; + } +- this.clearPendingDisconnectState(currentPending, disconnectContext); +- return false; ++ return true; + } + return true; + } +@@ -2890,13 +2965,13 @@ var AcpGatewayAgent = class { + const replayChunks = extractReplayChunks(message); + for (const chunk of replayChunks) await this.sessionUpdates.emit({ + sessionId, +- update: { ++ update: chunk.sessionUpdate === "user_message_chunk" || chunk.sessionUpdate === "agent_message_chunk" || chunk.sessionUpdate === "agent_thought_chunk" ? { + sessionUpdate: chunk.sessionUpdate, + content: { + type: "text", + text: chunk.text + } +- } ++ } : chunk + }); + } + } +diff --git a/dist/agent-D6kiZtPt.js b/dist/agent-D6kiZtPt.js +index af55bc0d0faa6c4abdcb72e6f905cc3774784508..c9438a1fad0004d7e8a812e17e3af69b52844371 100644 +--- a/dist/agent-D6kiZtPt.js ++++ b/dist/agent-D6kiZtPt.js +@@ -941,6 +941,15 @@ const agentHandlers = { + let resolvedGroupSpace = normalizedSpawned.groupSpace; + let spawnedByValue; + const inputProvenance = normalizeInputProvenance(request.inputProvenance); ++ const isRestartRecoveryResumeRun = canUseInternalRuntimeHandoff && inputProvenance?.kind === "internal_system" && inputProvenance.sourceTool === "main_session_restart_recovery"; ++ if (request.internalRestartRecoverySourceRunId !== void 0 && !isRestartRecoveryResumeRun) { ++ respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "restart recovery run lineage is reserved for main-session restart recovery.")); ++ return; ++ } ++ if (request.internalRestartRecoverySourceRunId !== void 0 && request.internalRestartRecoverySourceRunId === runId) { ++ respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "restart recovery must use a distinct run id from its source run.")); ++ return; ++ } + const preserveUserFacingSessionModelState = canUseInternalRuntimeHandoff && shouldPreserveUserFacingSessionStateForInputProvenance(inputProvenance); + const sessionEffects = requestedInternalSessionEffects ? "internal" : request.sessionEffects; + const suppressVisibleSessionEffects = sessionEffects === "internal"; +@@ -2192,7 +2201,8 @@ const agentHandlers = { + lifecycleGeneration + } : { + sessionKey: resolvedSessionKey, +- lifecycleGeneration ++ lifecycleGeneration, ++ ...request.internalRestartRecoverySourceRunId ? { resumedFromRunId: request.internalRestartRecoverySourceRunId } : {} + }); + } + const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId; +diff --git a/dist/agent-events-CRggPZCM.js b/dist/agent-events-CRggPZCM.js +index d219ef4933d12a485f9f29bc056fd8d39594d0a1..9f6c72ec51940362d08467068aff26a377d15f7b 100644 +--- a/dist/agent-events-CRggPZCM.js ++++ b/dist/agent-events-CRggPZCM.js +@@ -56,6 +56,7 @@ function registerAgentRunContext(runId, context) { + if (context.lifecycleGeneration && existing.lifecycleGeneration && context.lifecycleGeneration !== existing.lifecycleGeneration) return; + if (context.sessionKey && existing.sessionKey !== context.sessionKey) existing.sessionKey = context.sessionKey; + if (context.sessionId && existing.sessionId !== context.sessionId) existing.sessionId = context.sessionId; ++ if (context.resumedFromRunId && existing.resumedFromRunId !== context.resumedFromRunId) existing.resumedFromRunId = context.resumedFromRunId; + if (context.agentId && existing.agentId !== context.agentId) existing.agentId = context.agentId; + if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) existing.verboseLevel = context.verboseLevel; + if (context.isControlUiVisible !== void 0) existing.isControlUiVisible = context.isControlUiVisible; +@@ -182,11 +183,13 @@ function enrichAgentEvent(event) { + const sessionId = event.stream === "lifecycle" ? event.sessionId ?? context?.sessionId : event.sessionId; + const lifecycleGeneration = event.stream === "lifecycle" ? ownedLifecycleGeneration ?? state.lifecycleGeneration : ownedLifecycleGeneration; + const agentId = event.agentId ?? context?.agentId; ++ const resumedFromRunId = context?.resumedFromRunId; + const enriched = { + ...event, + sessionKey, + ...sessionId ? { sessionId } : {}, + ...agentId ? { agentId } : {}, ++ ...resumedFromRunId ? { resumedFromRunId } : {}, + seq: nextSeq, + ts: Date.now() + }; +diff --git a/dist/agent-tools-BD8WL7ny.js b/dist/agent-tools-BD8WL7ny.js +index c16af06126dfcd8070fa0eb203799aba7a16bd95..75fe92b0994ced97fc8f7014a76dacb0c4dedc65 100644 +--- a/dist/agent-tools-BD8WL7ny.js ++++ b/dist/agent-tools-BD8WL7ny.js +@@ -1132,0 +1133 @@ function createOpenClawCodingTools(options) { ++ runId: options?.runId, +diff --git a/dist/bash-tools-DHyGpWCr.js b/dist/bash-tools-DHyGpWCr.js +index def4eecc41bf57190eac84a5df1dcf1d7c70664b..ba0013d6148de2410bad04b392e84a8ab3a8529d 100644 +--- a/dist/bash-tools-DHyGpWCr.js ++++ b/dist/bash-tools-DHyGpWCr.js +@@ -100,0 +101,3 @@ function buildExecApprovalRequestToolParams(params) { ++ sessionId: params.sessionId, ++ runId: params.runId, ++ toolCallId: params.toolCallId, +@@ -214,0 +218,3 @@ async function buildHostApprovalDecisionParams(params) { ++ sessionId: params.sessionId, ++ runId: params.runId, ++ toolCallId: params.toolCallId, +@@ -1146,0 +1153,3 @@ async function processGatewayAllowlist(params) { ++ sessionId: params.sessionId, ++ runId: params.runId, ++ toolCallId: params.toolCallId, +@@ -1879,0 +1889,2 @@ async function executeNodeHostCommand(params) { ++ runId: params.runId, ++ toolCallId: params.toolCallId, +@@ -3216 +3227 @@ function createExecTool(defaults) { +- execute: async (_toolCallId, args, signal, onUpdate) => { ++ execute: async (toolCallId, args, signal, onUpdate) => { +@@ -3375,0 +3387,2 @@ function createExecTool(defaults) { ++ toolCallId, ++ runId: defaults?.runId, +@@ -3427,0 +3441,2 @@ function createExecTool(defaults) { ++ runId: defaults?.runId, ++ toolCallId, +diff --git a/dist/exec-approval-DRfKKxhu.js b/dist/exec-approval-DRfKKxhu.js +index f76d64d4041f579301e4ffacc38a20a7887567ef..eb4243089d8329eab00d9af9b106b46ef260df8c 100644 +--- a/dist/exec-approval-DRfKKxhu.js ++++ b/dist/exec-approval-DRfKKxhu.js +@@ -10,6 +10,7 @@ import { i as sanitizeExecApprovalWarningText, n as sanitizeExecApprovalDisplayT + import { t as analyzeCommandForPolicy } from "./policy-X6MHg5ni.js"; + import { a as buildSystemRunApprovalEnvBinding, i as buildSystemRunApprovalBinding } from "./system-run-command-Bd_agqvl.js"; + import { n as resolveSystemRunApprovalRequestContext } from "./system-run-approval-context-B9ONWoZl.js"; ++import { f as getAgentRunContext } from "./agent-events-CRggPZCM.js"; + import { a as handleApprovalWaitDecision, c as listVisiblePendingApprovalRequests, f as resolvePendingApprovalRecord, i as handleApprovalResolve, l as registerPendingApprovalRecord, n as bindApprovalReviewerDeviceIds, o as handlePendingApprovalRequest, p as respondPendingApprovalLookupError, r as buildRequestedApprovalEvent, s as isApprovalRecordVisibleToClient, t as bindApprovalRequesterMetadata, u as resolveApprovalDecisionParams } from "./approval-shared-BKEyXMsJ.js"; + //#region src/infra/command-analysis/explain.ts + function riskLabel(risk) { +@@ -225,6 +226,8 @@ function createExecApprovalHandlers(manager, opts) { + return; + } + const unavailableDecisions = normalizeExecApprovalUnavailableDecisions(p.unavailableDecisions); ++ const requestRunId = normalizeOptionalString(p.runId); ++ const resumedFromRunId = requestRunId ? getAgentRunContext(requestRunId)?.resumedFromRunId : void 0; + const request = { + command: sanitizedCommandText, + commandPreview: host === "node" || !approvalContext.commandPreview ? void 0 : sanitizeExecApprovalDisplayText(approvalContext.commandPreview), +@@ -248,6 +251,10 @@ function createExecApprovalHandlers(manager, opts) { + agentId: effectiveAgentId ?? null, + resolvedPath: p.resolvedPath ?? null, + sessionKey: effectiveSessionKey ?? null, ++ sessionId: normalizeOptionalString(p.sessionId) ?? null, ++ runId: requestRunId ?? null, ++ toolCallId: normalizeOptionalString(p.toolCallId) ?? null, ++ ...resumedFromRunId ? { resumedFromRunId } : {}, + turnSourceChannel: normalizeOptionalString(p.turnSourceChannel) ?? null, + turnSourceTo: normalizeOptionalString(p.turnSourceTo) ?? null, + turnSourceAccountId: normalizeOptionalString(p.turnSourceAccountId) ?? null, +diff --git a/dist/exec-approvals-bouecjdj.d.ts b/dist/exec-approvals-bouecjdj.d.ts +index c0a416851d957f1edd9e675fb74d69c4f3d1fdbb..30e33fc6fe5f31ee393ad25d0f2b24990ae0b5fe 100644 +--- a/dist/exec-approvals-bouecjdj.d.ts ++++ b/dist/exec-approvals-bouecjdj.d.ts +@@ -382,6 +382,10 @@ type ExecApprovalRequestPayload = { + agentId?: string | null; + resolvedPath?: string | null; + sessionKey?: string | null; ++ sessionId?: string | null; ++ runId?: string | null; ++ resumedFromRunId?: string; ++ toolCallId?: string | null; + turnSourceChannel?: string | null; + turnSourceTo?: string | null; + turnSourceAccountId?: string | null; +diff --git a/dist/main-session-restart-recovery-Ce8fihTV.js b/dist/main-session-restart-recovery-Ce8fihTV.js +index ba698afb03ca7043d475948afcbb18643719a011..5c976a1d0f5fc9b85e76f65684eaba2c2b42d887 100644 +--- a/dist/main-session-restart-recovery-Ce8fihTV.js ++++ b/dist/main-session-restart-recovery-Ce8fihTV.js +@@ -211,6 +211,8 @@ async function markStartupOrphanedMainSessionsForRecovery(params) { + entry, + sessionKey + })) continue; ++ const sourceRunId = normalizeOptionalString(entry.lifecycleRunId); ++ if (sourceRunId) entry.restartRecoveryDeliverySourceRunId = sourceRunId; + entry.abortedLastRun = true; + entry.updatedAt = Date.now(); + replacements.push({ +@@ -350,6 +352,8 @@ function resolveRestartRecoveryDeliveryContext(params) { + } + async function resumeMainSession(params) { + const sanitizedPendingText = typeof params.pendingFinalDeliveryText === "string" ? sanitizePendingFinalDeliveryText(params.pendingFinalDeliveryText) : ""; ++ const sourceRunIds = [...new Set((params.entry.restartRecoveryRuns ?? []).map((run) => typeof run.runId === "string" ? run.runId.trim() : "").filter(Boolean))]; ++ const sourceRunId = normalizeOptionalString(params.entry.restartRecoveryDeliverySourceRunId) ?? (sourceRunIds.length === 1 ? sourceRunIds[0] : void 0); + const deliveryContext = resolveRestartRecoveryDeliveryContext({ + cfg: params.cfg, + entry: params.entry, +@@ -359,9 +363,15 @@ async function resumeMainSession(params) { + const agentParams = { + message: buildResumeMessage(sanitizedPendingText), + sessionKey: params.sessionKey, ++ ...sourceRunId ? { internalRestartRecoverySourceRunId: sourceRunId } : {}, + idempotencyKey: crypto.randomUUID(), + deliver: Boolean(deliveryContext), +- lane: "main" ++ lane: "main", ++ inputProvenance: { ++ kind: "internal_system", ++ sourceSessionKey: params.sessionKey, ++ sourceTool: "main_session_restart_recovery" ++ } + }; + if (deliveryContext) { + agentParams.channel = deliveryContext.channel; +diff --git a/dist/schema-BuOFpc7K.js b/dist/schema-BuOFpc7K.js +index 50b16b4a3f159b698c6485e079e1c38ea41fda78..03ee242b547c3b841cc6a5819a4d0349a300228e 100644 +--- a/dist/schema-BuOFpc7K.js ++++ b/dist/schema-BuOFpc7K.js +@@ -138,6 +138,7 @@ const AgentInternalEventSchema = Type.Object({ + /** Stream event emitted by the agent runtime over the gateway protocol. */ + const AgentEventSchema = Type.Object({ + runId: NonEmptyString, ++ resumedFromRunId: Type.Optional(NonEmptyString), + seq: Type.Integer({ minimum: 0 }), + stream: NonEmptyString, + ts: Type.Integer({ minimum: 0 }), +@@ -280,6 +281,7 @@ const AgentParamsSchema = Type.Object({ + ])), + acpTurnSource: Type.Optional(Type.Literal("manual_spawn")), + internalRuntimeHandoffId: Type.Optional(NonEmptyString), ++ internalRestartRecoverySourceRunId: Type.Optional(NonEmptyString), + execApprovalFollowupExpectedSessionId: Type.Optional(NonEmptyString), + internalEvents: Type.Optional(Type.Array(AgentInternalEventSchema)), + inputProvenance: Type.Optional(InputProvenanceSchema), +@@ -2910,0 +2913,3 @@ const ExecApprovalRequestParamsSchema = Type.Object({ ++ sessionId: Type.Optional(Type.Union([Type.String(), Type.Null()])), ++ runId: Type.Optional(Type.Union([Type.String(), Type.Null()])), ++ toolCallId: Type.Optional(Type.Union([Type.String(), Type.Null()])), +@@ -3304,6 +3309,7 @@ const ChatInjectParamsSchema = Type.Object({ + /** Shared event fields preserve stream ordering and route events to the right session. */ + const ChatEventBaseSchema = { + runId: NonEmptyString, ++ resumedFromRunId: Type.Optional(NonEmptyString), + sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), + spawnedBy: Type.Optional(NonEmptyString), +diff --git a/dist/schema-DtyqV_v0.d.ts b/dist/schema-DtyqV_v0.d.ts +index ff8cd2829dc9f64d7fc8ec2bf77bb5f05947459d..f2f08299de269d42714b491343006250be0db4aa 100644 +--- a/dist/schema-DtyqV_v0.d.ts ++++ b/dist/schema-DtyqV_v0.d.ts +@@ -4,6 +4,7 @@ import { Static, TSchema, Type } from "typebox"; + /** Stream event emitted by the agent runtime over the gateway protocol. */ + declare const AgentEventSchema: Type.TObject<{ + runId: Type.TString; ++ resumedFromRunId: Type.TOptional; + seq: Type.TInteger; + stream: Type.TString; + ts: Type.TInteger; +@@ -110,6 +111,7 @@ declare const AgentParamsSchema: Type.TObject<{ + bootstrapContextRunKind: Type.TOptional, Type.TLiteral<"heartbeat">, Type.TLiteral<"cron">]>>; + acpTurnSource: Type.TOptional>; + internalRuntimeHandoffId: Type.TOptional; ++ internalRestartRecoverySourceRunId: Type.TOptional; + execApprovalFollowupExpectedSessionId: Type.TOptional; + internalEvents: Type.TOptional; +@@ -3065,6 +3067,7 @@ declare const ProtocolSchemas: { + bootstrapContextRunKind: import("typebox").TOptional, import("typebox").TLiteral<"heartbeat">, import("typebox").TLiteral<"cron">]>>; + acpTurnSource: import("typebox").TOptional>; + internalRuntimeHandoffId: import("typebox").TOptional; ++ internalRestartRecoverySourceRunId: import("typebox").TOptional; + execApprovalFollowupExpectedSessionId: import("typebox").TOptional; + internalEvents: import("typebox").TOptional; +@@ -7573,6 +7576,9 @@ declare const ExecApprovalRequestParamsSchema: Type.TObject<{ + agentId: Type.TOptional>; + resolvedPath: Type.TOptional>; + sessionKey: Type.TOptional>; ++ sessionId: Type.TOptional>; ++ runId: Type.TOptional>; ++ toolCallId: Type.TOptional>; + turnSourceChannel: Type.TOptional>; + turnSourceTo: Type.TOptional>; + turnSourceAccountId: Type.TOptional>; +@@ -7867,6 +7873,7 @@ declare const ChatEventSchema: Type.TUnion<[Type.TObject<{ + replace: Type.TOptional; + usage: Type.TOptional; + runId: Type.TString; ++ resumedFromRunId: Type.TOptional; + sessionKey: Type.TString; + agentId: Type.TOptional; + spawnedBy: Type.TOptional; +@@ -7877,6 +7884,7 @@ declare const ChatEventSchema: Type.TUnion<[Type.TObject<{ + usage: Type.TOptional; + stopReason: Type.TOptional; + runId: Type.TString; ++ resumedFromRunId: Type.TOptional; + sessionKey: Type.TString; + agentId: Type.TOptional; + spawnedBy: Type.TOptional; +@@ -7887,6 +7895,7 @@ declare const ChatEventSchema: Type.TUnion<[Type.TObject<{ + errorMessage: Type.TOptional; + stopReason: Type.TOptional; + runId: Type.TString; ++ resumedFromRunId: Type.TOptional; + sessionKey: Type.TString; + agentId: Type.TOptional; + spawnedBy: Type.TOptional; +@@ -7899,6 +7908,7 @@ declare const ChatEventSchema: Type.TUnion<[Type.TObject<{ + usage: Type.TOptional; + stopReason: Type.TOptional; + runId: Type.TString; ++ resumedFromRunId: Type.TOptional; + sessionKey: Type.TString; + agentId: Type.TOptional; + spawnedBy: Type.TOptional; +diff --git a/dist/server-chat-wgxNCdC3.js b/dist/server-chat-wgxNCdC3.js +index e03fecec0657ddd63833fd4804efba1d21d2f361..6fa31407a9c30d42cc9d907e2f1d862edf5bba12 100644 +--- a/dist/server-chat-wgxNCdC3.js ++++ b/dist/server-chat-wgxNCdC3.js +@@ -544,14 +544,21 @@ function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSess + chatRunState.deltaSentAt.set(clientRunId, now); + }; + const sendChatPayload = (sessionKey, payload, opts) => { ++ const payloadRecord = payload && typeof payload === "object" ? payload : void 0; ++ const runId = typeof payloadRecord?.runId === "string" ? payloadRecord.runId : void 0; ++ const resumedFromRunId = runId ? getAgentRunContext(runId)?.resumedFromRunId : void 0; ++ const projectedPayload = resumedFromRunId && payloadRecord ? { ++ ...payloadRecord, ++ resumedFromRunId ++ } : payload; + const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId); + if (opts?.controlUiVisible ?? true) { +- broadcast("chat", payload, { dropIfSlow: opts?.dropIfSlow }); +- sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId); ++ broadcast("chat", projectedPayload, { dropIfSlow: opts?.dropIfSlow }); ++ sendNodeSessionPayloadForAgent(sessionKey, "chat", projectedPayload, opts?.agentId); + return; + } + const recipients = sessionMessageSubscribers.get(deliverySessionKey); +- if (recipients.size > 0) broadcastToConnIds("chat", payload, recipients, { dropIfSlow: opts?.dropIfSlow }); ++ if (recipients.size > 0) broadcastToConnIds("chat", projectedPayload, recipients, { dropIfSlow: opts?.dropIfSlow }); + }; + const emitChatTerminal = (sessionKey, clientRunId, sourceRunId, seq, jobState, error, stopReason, errorKind, opts) => { + const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, { suppressLeadFragments: false }); +@@ -721,15 +728,18 @@ function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSess + } + if (lifecyclePhase !== null && lifecyclePhase !== "error") clearPendingTerminalLifecycleError(evt.runId); + const spawnedBy = sessionKey ? resolveSpawnedBy(sessionKey) : null; ++ const resumedFromRunId = runContext?.resumedFromRunId; + const agentPayload = sessionKey ? { + ...eventForClients, + sessionKey, + ...sessionAgentId ? { agentId: sessionAgentId } : {}, + ...spawnedBy && { spawnedBy }, +- ...isHeartbeat !== void 0 && { isHeartbeat } ++ ...isHeartbeat !== void 0 && { isHeartbeat }, ++ ...resumedFromRunId ? { resumedFromRunId } : {} + } : { + ...eventForClients, +- ...isHeartbeat !== void 0 && { isHeartbeat } ++ ...isHeartbeat !== void 0 && { isHeartbeat }, ++ ...resumedFromRunId ? { resumedFromRunId } : {} + }; + const hasSessionMessageSubscribers = sessionKey ? sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId)).size > 0 : false; + const last = agentRunSeq.get(evt.runId) ?? 0; +@@ -799,7 +809,10 @@ function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSess + dropIfSlow: true + }); + if (isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) { +- const sessionSubscribers = excludeConnIds(sessionEventSubscribers.getAll(), runToolRecipients); ++ const sessionSubscribers = excludeConnIds(new Set([ ++ ...sessionEventSubscribers.getAll(), ++ ...sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId)) ++ ]), runToolRecipients); + if (sessionSubscribers.size > 0) broadcastToConnIds("session.tool", { + ...agentPayload, + ...buildSessionEventSnapshot(sessionKey, void 0, sessionAgentId) +diff --git a/dist/session-lifecycle-state-Czgc8l0p.js b/dist/session-lifecycle-state-Czgc8l0p.js +index db68c120f69a4f2bc4121f3b75a456319fd227ed..21a1198bc7273b8aeeb8c91466a8cf85d32932ef 100644 +--- a/dist/session-lifecycle-state-Czgc8l0p.js ++++ b/dist/session-lifecycle-state-Czgc8l0p.js +@@ -97,10 +97,18 @@ function derivePersistedSessionLifecyclePatch(params) { + if (remainingRuns.length > 0) return { restartRecoveryRuns: remainingRuns }; + patch.restartRecoveryRuns = void 0; + } +- return patch; ++ const phase = resolveLifecyclePhase(params.event); ++ return { ++ ...patch, ++ ...phase === "start" ? { lifecycleRunId: runId } : patch.status && patch.status !== "running" ? { lifecycleRunId: void 0 } : {} ++ }; + } + function deriveGatewaySessionLifecycleProjectionPatch(params) { +- const { restartRecoveryRuns: _restartRecoveryRuns, ...patch } = derivePersistedSessionLifecyclePatch(params); ++ const { ++ restartRecoveryRuns: _restartRecoveryRuns, ++ lifecycleRunId: _lifecycleRunId, ++ ...patch ++ } = derivePersistedSessionLifecyclePatch(params); + return patch; + } + function isRestartRecoveryLifecycleEvent(params) { +diff --git a/dist/store-BJJhlPrk.js b/dist/store-BJJhlPrk.js +index 15b9c3b2aea87e1f1782e8669de218d30afd9a00..e62c2a2061b639ba7291d9c19e6e8603beaae19e 100644 +--- a/dist/store-BJJhlPrk.js ++++ b/dist/store-BJJhlPrk.js +@@ -147,6 +147,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEYS = /* @__PURE__ */ new Set([ + "pluginExtensionSlotKeys", + "pluginNextTurnInjections", + "sessionId", ++ "lifecycleRunId", + "lifecycleRevision", + "updatedAt", + "archivedAt", +@@ -229,6 +230,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEYS = /* @__PURE__ */ new Set([ + "pendingFinalDeliveryIntentId", + "restartRecoveryDeliveryContext", + "restartRecoveryDeliveryRunId", ++ "restartRecoveryDeliverySourceRunId", + "totalTokensFresh", + "estimatedCostUsd", + "cacheRead", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ea77f90..38c70e0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,11 @@ settings: overrides: isbinaryfile: ^5.0.0 +patchedDependencies: + openclaw@2026.7.1-2: + hash: fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3 + path: patches/openclaw@2026.7.1-2.patch + importers: .: @@ -65,7 +70,7 @@ importers: version: 1.3.7 '@larksuite/openclaw-lark': specifier: 2026.7.9 - version: 2026.7.9(openclaw@2026.7.1-2(encoding@0.1.13)) + version: 2026.7.9(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)) '@larksuiteoapi/node-sdk': specifier: ^1.61.1 version: 1.62.0 @@ -74,13 +79,13 @@ importers: version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@openclaw/discord': specifier: 2026.7.1 - version: 2026.7.1(openclaw@2026.7.1-2(encoding@0.1.13)) + version: 2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)) '@openclaw/qqbot': specifier: 2026.7.1 - version: 2026.7.1(openclaw@2026.7.1-2(encoding@0.1.13)) + version: 2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)) '@openclaw/whatsapp': specifier: 2026.7.1 - version: 2026.7.1(openclaw@2026.7.1-2(encoding@0.1.13)) + version: 2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)) '@playwright/test': specifier: ^1.56.1 version: 1.59.0 @@ -125,7 +130,7 @@ importers: version: 0.34.48 '@soimy/dingtalk': specifier: 3.6.6 - version: 3.6.6(openclaw@2026.7.1-2(encoding@0.1.13)) + version: 3.6.6(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(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) @@ -140,7 +145,7 @@ importers: version: 1.1.0 '@tencent-weixin/openclaw-weixin': specifier: ^2.4.6 - version: 2.4.6(openclaw@2026.7.1-2(encoding@0.1.13)) + version: 2.4.6(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -173,7 +178,7 @@ importers: version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0)) '@wecom/wecom-openclaw-plugin': specifier: 2026.7.2 - version: 2026.7.2(openclaw@2026.7.1-2(encoding@0.1.13)) + version: 2026.7.2(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)) '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5) @@ -263,7 +268,7 @@ importers: version: 2.1.3 openclaw: specifier: 2026.7.1-2 - version: 2026.7.1-2(encoding@0.1.13) + version: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13) opusscript: specifier: ^0.1.1 version: 0.1.1 @@ -4213,7 +4218,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4672,8 +4677,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: - resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7} + libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7: + resolution: {commit: bcea72df9ec34d9d9140ab30619cf479c7c144c7, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git} version: 6.0.0 lie@3.3.0: @@ -8017,7 +8022,7 @@ snapshots: '@kurkle/color@0.3.4': {} - '@larksuite/openclaw-lark@2026.7.9(openclaw@2026.7.1-2(encoding@0.1.13))': + '@larksuite/openclaw-lark@2026.7.9(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))': dependencies: '@larksuiteoapi/node-sdk': 1.66.1 '@sinclair/typebox': 0.34.49 @@ -8025,7 +8030,7 @@ snapshots: undici-types: 8.3.0 zod: 4.4.3 optionalDependencies: - openclaw: 2026.7.1-2(encoding@0.1.13) + openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13) transitivePeerDependencies: - bufferutil - debug @@ -8297,9 +8302,9 @@ snapshots: - ws - zod - '@openclaw/discord@2026.7.1(openclaw@2026.7.1-2(encoding@0.1.13))': + '@openclaw/discord@2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))': optionalDependencies: - openclaw: 2026.7.1-2(encoding@0.1.13) + openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13) '@openclaw/fs-safe@0.4.1': optionalDependencies: @@ -8310,13 +8315,13 @@ snapshots: dependencies: undici: 8.5.0 - '@openclaw/qqbot@2026.7.1(openclaw@2026.7.1-2(encoding@0.1.13))': + '@openclaw/qqbot@2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))': optionalDependencies: - openclaw: 2026.7.1-2(encoding@0.1.13) + openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13) - '@openclaw/whatsapp@2026.7.1(openclaw@2026.7.1-2(encoding@0.1.13))': + '@openclaw/whatsapp@2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))': optionalDependencies: - openclaw: 2026.7.1-2(encoding@0.1.13) + openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13) '@opentelemetry/semantic-conventions@1.43.0': {} @@ -9000,7 +9005,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - '@soimy/dingtalk@3.6.6(openclaw@2026.7.1-2(encoding@0.1.13))': + '@soimy/dingtalk@3.6.6(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))': dependencies: axios: 1.13.6(debug@4.4.3) dingtalk-stream: 2.1.5 @@ -9009,7 +9014,7 @@ snapshots: pdf-parse: 2.4.5 zod: 4.4.3 optionalDependencies: - openclaw: 2026.7.1-2(encoding@0.1.13) + openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13) transitivePeerDependencies: - bufferutil - debug @@ -9055,9 +9060,9 @@ snapshots: dependencies: qrcode-terminal: 0.12.0 - '@tencent-weixin/openclaw-weixin@2.4.6(openclaw@2026.7.1-2(encoding@0.1.13))': + '@tencent-weixin/openclaw-weixin@2.4.6(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))': dependencies: - openclaw: 2026.7.1-2(encoding@0.1.13) + openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13) qrcode-terminal: 0.12.0 zod: 4.4.3 @@ -9546,7 +9551,7 @@ snapshots: - debug - utf-8-validate - '@wecom/wecom-openclaw-plugin@2026.7.2(openclaw@2026.7.1-2(encoding@0.1.13))': + '@wecom/wecom-openclaw-plugin@2026.7.2(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))': dependencies: '@wecom/aibot-node-sdk': 1.0.6 fast-xml-parser: 5.7.3 @@ -9554,7 +9559,7 @@ snapshots: undici: 7.24.6 zod: 4.4.3 optionalDependencies: - openclaw: 2026.7.1-2(encoding@0.1.13) + openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13) transitivePeerDependencies: - bufferutil - debug @@ -9566,7 +9571,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7 + libsignal: git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7 lru-cache: 11.2.7 music-metadata: 11.12.3 p-queue: 9.1.0 @@ -11824,7 +11829,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: + libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7: dependencies: curve25519-js: 0.0.4 protobufjs: 7.5.8 @@ -12695,7 +12700,7 @@ snapshots: ws: 8.21.0 zod: 4.4.3 - openclaw@2026.7.1-2(encoding@0.1.13): + openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13): dependencies: '@agentclientprotocol/sdk': 1.1.0(zod@4.4.3) '@anthropic-ai/sdk': 0.109.1(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 39a85265..fdebdd2d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,6 @@ packages: ignoredBuiltDependencies: - electron - esbuild + +patchedDependencies: + openclaw@2026.7.1-2: patches/openclaw@2026.7.1-2.patch diff --git a/src/components/web-browser/WebBrowserHost.tsx b/src/components/web-browser/WebBrowserHost.tsx index 00c47771..0036881f 100644 --- a/src/components/web-browser/WebBrowserHost.tsx +++ b/src/components/web-browser/WebBrowserHost.tsx @@ -135,6 +135,12 @@ export function WebBrowserHost(): React.ReactElement | null { setLoading(node.isLoading()); void navigateToPreview(); }; + const onDomReady = () => { + // did-attach can be missed when React mounts during guest initialization. + // dom-ready confirms Main has registered the guest and is safe to navigate. + attachedWebviewRef.current = node; + void navigateToPreview(); + }; const onDidStartLoading = () => setLoading(true); const onDidStopLoading = () => setLoading(false); const onDidFailLoad = (event: DidFailLoadEvent) => { @@ -149,12 +155,14 @@ export function WebBrowserHost(): React.ReactElement | null { }; node.addEventListener('did-attach', onDidAttach); + node.addEventListener('dom-ready', onDomReady); node.addEventListener('did-start-loading', onDidStartLoading); node.addEventListener('did-stop-loading', onDidStopLoading); node.addEventListener('did-fail-load', onDidFailLoad); node.addEventListener('render-process-gone', onRenderProcessGone); removeListenersRef.current = () => { node.removeEventListener('did-attach', onDidAttach); + node.removeEventListener('dom-ready', onDomReady); node.removeEventListener('did-start-loading', onDidStartLoading); node.removeEventListener('did-stop-loading', onDidStopLoading); node.removeEventListener('did-fail-load', onDidFailLoad); diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index 2b4208e4..62298a82 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -289,6 +289,7 @@ export function ChatInput({ const chatComposerStatusComponents = rendererExtensionRegistry.getChatComposerStatusComponents(); const isGatewayUsable = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false; const inputDisabled = disabled; + const gatewayUnavailable = !isGatewayUsable; const workspaceSelectorDisabled = workspaceReadOnly || inputDisabled || sending || !onSelectWorkspace; const skillTokenRanges = useMemo(() => findSkillTokenRanges(input), [input]); const openArtifactPreview = useArtifactPanel((s) => s.openPreview); @@ -977,7 +978,7 @@ export function ChatInput({ isComposingRef.current = false; }} onPaste={handlePaste} - placeholder={inputDisabled ? t('composer.gatewayDisconnectedPlaceholder') : ''} + placeholder={inputDisabled && gatewayUnavailable ? t('composer.gatewayDisconnectedPlaceholder') : ''} disabled={inputDisabled} data-testid="chat-composer-input" className={cn( diff --git a/tests/e2e/chat-acp-inline-timeline.spec.ts b/tests/e2e/chat-acp-inline-timeline.spec.ts index 1cea0639..22ec5c56 100644 --- a/tests/e2e/chat-acp-inline-timeline.spec.ts +++ b/tests/e2e/chat-acp-inline-timeline.spec.ts @@ -844,6 +844,11 @@ test.describe('ClawX ACP inline timeline', () => { messageId: 'history-user', content: [{ type: 'text', text: 'Replay the tool call' }], }, + { + sessionUpdate: 'agent_message_chunk', + messageId: 'history-assistant-before', + content: { type: 'text', text: 'Before the historical tool' }, + }, { sessionUpdate: 'tool_call', toolCallId: 'history-tool', @@ -853,9 +858,9 @@ test.describe('ClawX ACP inline timeline', () => { locations: [], }, { - sessionUpdate: 'agent_message', - messageId: 'history-assistant', - content: [{ type: 'text', text: 'Historical answer' }], + sessionUpdate: 'agent_message_chunk', + messageId: 'history-assistant-after', + content: { type: 'text', text: 'After the historical tool' }, }, ]); @@ -868,7 +873,14 @@ test.describe('ClawX ACP inline timeline', () => { await page.getByTestId('acp-tool-toggle').click(); await expect(card).toHaveAttribute('data-expanded', 'true'); await expect(card).toContainText('historical output'); - await expect(page.getByTestId('acp-assistant-turn')).toContainText('Historical answer'); + const turn = page.getByTestId('acp-assistant-turn'); + await expect(turn).toContainText('Before the historical tool'); + await expect(turn).toContainText('After the historical tool'); + const orderedParts = turn.locator('[data-testid="acp-assistant-message"], [data-testid="acp-tool-call-card"]'); + await expect(orderedParts).toHaveCount(3); + await expect(orderedParts.nth(0)).toContainText('Before the historical tool'); + await expect(orderedParts.nth(1)).toContainText('Historical tool'); + await expect(orderedParts.nth(2)).toContainText('After the historical tool'); } finally { await closeElectronApp(app); } diff --git a/tests/e2e/chat-workspace-context.spec.ts b/tests/e2e/chat-workspace-context.spec.ts index b9cb9615..965ec15b 100644 --- a/tests/e2e/chat-workspace-context.spec.ts +++ b/tests/e2e/chat-workspace-context.spec.ts @@ -316,8 +316,26 @@ test.describe('ClawX chat workspace context', () => { } const workspaceSelector = page.getByTestId('chat-workspace-selector'); + const composerInput = page.getByTestId('chat-composer-input'); await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL, { timeout: 30_000 }); await expect(workspaceSelector).toHaveAttribute('aria-disabled', 'true'); + await expect(composerInput).toBeEnabled(); + + await composerInput.evaluate((element) => { + const input = element as HTMLTextAreaElement; + const state = { showedGatewayDisconnected: false }; + const observe = () => { + if (input.placeholder === 'Gateway not connected...') { + state.showedGatewayDisconnected = true; + } + }; + const observer = new MutationObserver(observe); + observer.observe(input, { attributes: true, attributeFilter: ['placeholder'] }); + observe(); + (globalThis as unknown as { + __newChatComposerPlaceholderObservation?: { observer: MutationObserver; state: typeof state }; + }).__newChatComposerPlaceholderObservation = { observer, state }; + }); await page.getByTestId('sidebar-new-chat').click(); @@ -333,6 +351,18 @@ test.describe('ClawX chat workspace context', () => { await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL); await expect(workspaceSelector).toHaveAttribute('title', SESSION_WORKSPACE); await expect(workspaceSelector).not.toHaveAttribute('aria-disabled', 'true'); + await expect(composerInput).toBeEnabled(); + const showedGatewayDisconnected = await composerInput.evaluate(() => { + const observation = (globalThis as unknown as { + __newChatComposerPlaceholderObservation?: { + observer: MutationObserver; + state: { showedGatewayDisconnected: boolean }; + }; + }).__newChatComposerPlaceholderObservation; + observation?.observer.disconnect(); + return observation?.state.showedGatewayDisconnected ?? false; + }); + expect(showedGatewayDisconnected).toBe(false); } finally { await closeElectronApp(app); } diff --git a/tests/unit/openclaw-restart-recovery-patch.test.ts b/tests/unit/openclaw-restart-recovery-patch.test.ts new file mode 100644 index 00000000..1e7daa93 --- /dev/null +++ b/tests/unit/openclaw-restart-recovery-patch.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment node +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { runInNewContext } from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const root = path.resolve(import.meta.dirname, '../..'); +const execFileAsync = promisify(execFile); + +function assertValidUnifiedDiffHunks(patch: string): void { + const lines = patch.split('\n'); + let hunkCount = 0; + + for (let index = 0; index < lines.length; index += 1) { + const header = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(lines[index] ?? ''); + if (!header) continue; + + hunkCount += 1; + const expectedOld = Number(header[1] ?? 1); + const expectedNew = Number(header[2] ?? 1); + let oldLines = 0; + let newLines = 0; + + for (index += 1; index < lines.length; index += 1) { + const line = lines[index] ?? ''; + if (line.startsWith('@@ ') || line.startsWith('diff --git ')) { + index -= 1; + break; + } + if (line === '' && index === lines.length - 1) break; + if (line.startsWith(' ')) { + oldLines += 1; + newLines += 1; + } else if (line.startsWith('-')) { + oldLines += 1; + } else if (line.startsWith('+')) { + newLines += 1; + } else if (!line.startsWith('\\')) { + throw new Error(`Invalid unified diff line ${index + 1}: ${line}`); + } + } + + expect({ oldLines, newLines }).toEqual({ + oldLines: expectedOld, + newLines: expectedNew, + }); + } + + expect(hunkCount).toBeGreaterThan(0); +} + +describe('OpenClaw restart recovery patch', () => { + it('registers the pinned runtime patch through the pnpm workspace', async () => { + const workspace = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8'); + const lockfile = await readFile(path.join(root, 'pnpm-lock.yaml'), 'utf8'); + const patch = await readFile(path.join(root, 'patches/openclaw@2026.7.1-2.patch')); + const patchHash = createHash('sha256').update(patch).digest('hex'); + + expect(workspace).toContain( + 'openclaw@2026.7.1-2: patches/openclaw@2026.7.1-2.patch', + ); + expect(lockfile).toContain(`hash: ${patchHash}`); + expect(lockfile).toContain('path: patches/openclaw@2026.7.1-2.patch'); + }); + + it('carries trusted recovery lineage through Gateway events into ACP', async () => { + const patch = await readFile( + path.join(root, 'patches/openclaw@2026.7.1-2.patch'), + 'utf8', + ); + + expect(patch).toContain('internalRestartRecoverySourceRunId'); + expect(patch).toContain('canUseInternalRuntimeHandoff && inputProvenance?.kind'); + expect(patch).toContain('resumedFromRunId'); + expect(patch).toContain('pending.sendAccepted = true'); + expect(patch).toContain('pending.disconnectContext = void 0'); + expect(patch).toContain('ACP_GATEWAY_ACCEPTED_PROMPT_RECOVERY_GRACE_MS = 6e4'); + expect(patch).toContain('deadline === "accepted-recovery"'); + expect(patch).toContain('const waitedRunId = pending.idempotencyKey'); + expect(patch).toContain('status: "failed"'); + expect(patch).toContain('getAgentRunContext(requestRunId)?.resumedFromRunId'); + expect(patch).toContain('runId: params.runId'); + expect(patch).toContain('runId: options?.runId'); + expect(patch).toContain('execute: async (toolCallId, args, signal, onUpdate)'); + expect(patch).toContain('runId: defaults?.runId'); + expect(patch).toContain('runId: Type.Optional(Type.Union([Type.String(), Type.Null()]))'); + expect(patch).toContain('toolCallId: params.toolCallId'); + expect(patch).toContain('restart recovery must use a distinct run id'); + expect(patch).toContain('entry.restartRecoveryDeliverySourceRunId = sourceRunId'); + expect(patch).not.toContain('!entry.restartRecoveryDeliverySourceRunId && sourceRunId'); + expect(patch).toContain('normalizeOptionalString(params.entry.restartRecoveryDeliverySourceRunId)'); + expect(patch).toContain('phase === "start" ? { lifecycleRunId: runId }'); + expect(patch).toContain('lifecycleRunId: _lifecycleRunId'); + expect(patch).toContain('this.gateway.request("sessions.messages.subscribe", { key: pending.sessionKey })'); + expect(patch).not.toContain('this.gateway.request("sessions.subscribe", {})'); + expect(patch).toContain('evt.event === "session.tool"'); + expect(patch).not.toContain('diff --git a/scripts/README.md'); + assertValidUnifiedDiffHunks(patch); + }); + + it('preserves recovered tool boundaries in live delivery and transcript replay', async () => { + const patch = await readFile( + path.join(root, 'patches/openclaw@2026.7.1-2.patch'), + 'utf8', + ); + + expect(patch).toContain('const resumedFromRunId = runContext?.resumedFromRunId'); + expect(patch).toContain('...sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId))'); + expect(patch).toContain('function extractToolResultReplay(message)'); + expect(patch).toContain('extractToolCallContent(message.content)'); + expect(patch).toContain('typedBlock.type === "toolCall"'); + expect(patch).toContain('sessionUpdate: "tool_call_update"'); + expect(patch).toContain('chunk.sessionUpdate === "user_message_chunk"'); + expect(patch).toContain('const subscriptionReady = Promise.all'); + expect(patch).toContain('subscriptionReady.then(() => this.reconcilePendingPrompts'); + }); + + it('executes the pinned transcript fallback as ordered native ACP updates', async () => { + const bundle = await readFile( + path.join(root, 'node_modules/openclaw/dist/acp-cli-BXc5GttU.js'), + 'utf8', + ); + const start = bundle.indexOf('function extractToolResultReplay(message)'); + const end = bundle.indexOf('//#endregion', start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + + const context = { + normalizeOptionalString: (value: unknown) => ( + typeof value === 'string' && value.trim() ? value.trim() : undefined + ), + asOptionalRecord: (value: unknown) => ( + value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined + ), + formatToolTitle: (name: string | undefined) => name ?? 'tool', + inferToolKind: () => 'other', + extractToolCallLocations: () => undefined, + extractToolCallContent: (value: unknown) => ( + typeof value === 'string' + ? [{ type: 'content', content: { type: 'text', text: value } }] + : undefined + ), + extractReplayChunks: undefined as ((message: Record) => unknown[]) | undefined, + }; + runInNewContext( + `${bundle.slice(start, end)}\nglobalThis.extractReplayChunks = extractReplayChunks;`, + context, + ); + const extractReplayChunks = context.extractReplayChunks; + expect(extractReplayChunks).toBeTypeOf('function'); + + expect(extractReplayChunks?.({ + role: 'assistant', + content: [ + { type: 'text', text: 'Before tool' }, + { type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'src/app.ts' } }, + { type: 'text', text: 'After tool' }, + ], + })).toEqual([ + { sessionUpdate: 'agent_message_chunk', text: 'Before tool' }, + { + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'read', + status: 'in_progress', + rawInput: { path: 'src/app.ts' }, + kind: 'other', + locations: undefined, + }, + { sessionUpdate: 'agent_message_chunk', text: 'After tool' }, + ]); + expect(extractReplayChunks?.({ + role: 'toolResult', + toolCallId: 'call-1', + content: 'plain tool output', + })).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + rawOutput: { content: 'plain tool output' }, + content: [{ type: 'content', content: { type: 'text', text: 'plain tool output' } }], + locations: undefined, + }, + ]); + }); + + it('passes execution identity through the pinned approval request builder', async () => { + const bundle = await readFile( + path.join(root, 'node_modules/openclaw/dist/bash-tools-DHyGpWCr.js'), + 'utf8', + ); + const start = bundle.indexOf('function buildExecApprovalRequestToolParams(params)'); + const end = bundle.indexOf('\nfunction parseDecision', start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + + const context = { + DEFAULT_APPROVAL_TIMEOUT_MS: 60_000, + buildExecApprovalRequestToolParams: undefined as ((params: Record) => Record) | undefined, + }; + runInNewContext( + `${bundle.slice(start, end)}\nglobalThis.buildExecApprovalRequestToolParams = buildExecApprovalRequestToolParams;`, + context, + ); + + expect(context.buildExecApprovalRequestToolParams?.({ + id: 'approval-1', + sessionId: 'session-1', + runId: 'recovery-run', + toolCallId: 'tool-1', + })).toMatchObject({ + id: 'approval-1', + sessionId: 'session-1', + runId: 'recovery-run', + toolCallId: 'tool-1', + timeoutMs: 60_000, + twoPhase: true, + }); + }); + + it('keeps all patched runtime chunks syntactically valid', async () => { + for (const file of [ + 'agent-tools-BD8WL7ny.js', + 'bash-tools-DHyGpWCr.js', + 'exec-approval-DRfKKxhu.js', + 'schema-BuOFpc7K.js', + ]) { + await expect(execFileAsync(process.execPath, [ + '--check', + path.join(root, 'node_modules/openclaw/dist', file), + ])).resolves.toMatchObject({ stderr: '' }); + } + }); +}); diff --git a/tests/unit/web-browser-host.test.tsx b/tests/unit/web-browser-host.test.tsx index 0fa01432..5c8ac240 100644 --- a/tests/unit/web-browser-host.test.tsx +++ b/tests/unit/web-browser-host.test.tsx @@ -131,6 +131,21 @@ describe('HTML preview host', () => { }); }); + it('loads the selected local HTML when the guest becomes DOM-ready', async () => { + useArtifactPanel.setState({ + open: true, + tab: 'preview', + focusedFile: htmlFile(), + htmlPreviewAnchor: makeAnchor(), + }); + render(); + + fireEvent(webview(), new Event('dom-ready')); + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith('file:///workspace/site%20one.html'); + }); + }); + it('keeps the guest mounted but inert when Preview is hidden', () => { useArtifactPanel.setState({ open: true,