chore: move chart release note to PR

This commit is contained in:
Peter Steinberger 2026-07-09 14:54:02 +01:00 committed by GitHub
parent 2bae3e22cb
commit 4f92d51287
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1638 additions and 2504 deletions

View file

@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Google Chat request deadlines:** bound control calls to 30 seconds while giving media transfers size-aware total budgets and a separate 30-second stalled-body guard, preventing hung Chat API requests without breaking large attachment uploads. (#102227) Thanks @hugenshen.
- **Browser actions on Node 24:** keep browser request cancellation bound to the client and response lifetime instead of Node 24.16+'s prematurely aborted body-stream signal, preventing valid POST actions from failing after JSON parsing. Thanks @obviyus and @vincentkoc.
- **SecretRef model credentials:** keep resolved provider secrets behind process-local sentinels through auth storage, stream setup, SDK configuration, and managed local-provider probing, then inject plaintext only at the final network or provider-plugin boundary while retaining exact-value log redaction. (#102008, #102009)
- **Lean local model shell access:** keep `exec` directly visible beside the default structured Tool Search controls so coding-tuned local models can use their shell fallback instead of searching for missing domain tools. (#87587) Thanks @vincentkoc.

View file

@ -195,7 +195,7 @@
},
{
"kind": "ui-state-text",
"line": 640,
"line": 639,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Offline",
"surface": "android",
@ -203,7 +203,7 @@
},
{
"kind": "conditional-branch",
"line": 2914,
"line": 2913,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.",
"surface": "android",
@ -211,7 +211,7 @@
},
{
"kind": "conditional-branch",
"line": 2916,
"line": 2915,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.",
"surface": "android",
@ -219,7 +219,7 @@
},
{
"kind": "conditional-branch",
"line": 2918,
"line": 2917,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Failed: couldn't reach the secure gateway endpoint for this host.",
"surface": "android",
@ -425,22 +425,6 @@
"surface": "android",
"id": "native.android.5b17d331b254e403"
},
{
"kind": "conditional-branch",
"line": 54,
"path": "apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt",
"source": "$emoji $label",
"surface": "android",
"id": "native.android.1623b8cd55455bfc"
},
{
"kind": "conditional-branch",
"line": 54,
"path": "apps/android/app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt",
"source": "$emoji $label: $detailLine",
"surface": "android",
"id": "native.android.a393de564f65660f"
},
{
"kind": "conditional-branch",
"line": 47,
@ -979,7 +963,7 @@
},
{
"kind": "conditional-branch",
"line": 86,
"line": 80,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Setup code expired",
"surface": "android",
@ -987,7 +971,7 @@
},
{
"kind": "conditional-branch",
"line": 87,
"line": 81,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Gateway token needed",
"surface": "android",
@ -995,7 +979,7 @@
},
{
"kind": "conditional-branch",
"line": 88,
"line": 82,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Gateway token not configured",
"surface": "android",
@ -1003,7 +987,7 @@
},
{
"kind": "conditional-branch",
"line": 89,
"line": 83,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Gateway password needed",
"surface": "android",
@ -1011,7 +995,7 @@
},
{
"kind": "conditional-branch",
"line": 90,
"line": 84,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Gateway password invalid",
"surface": "android",
@ -1019,7 +1003,7 @@
},
{
"kind": "conditional-branch",
"line": 91,
"line": 85,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Gateway password not configured",
"surface": "android",
@ -1027,7 +1011,7 @@
},
{
"kind": "conditional-branch",
"line": 92,
"line": 86,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Gateway access needs review",
"surface": "android",
@ -1035,7 +1019,7 @@
},
{
"kind": "conditional-branch",
"line": 93,
"line": 87,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Saved auth invalid",
"surface": "android",
@ -1043,7 +1027,7 @@
},
{
"kind": "conditional-branch",
"line": 94,
"line": 88,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Device identity required",
"surface": "android",
@ -1051,7 +1035,7 @@
},
{
"kind": "ui-toast",
"line": 177,
"line": 171,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
"source": "Copied gateway diagnostics",
"surface": "android",
@ -3825,526 +3809,6 @@
"surface": "android",
"id": "native.android.f36439e78187c554"
},
{
"kind": "ui-call",
"line": 475,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "DEVICE",
"surface": "android",
"id": "native.android.46ba30c035d8f9b3"
},
{
"kind": "ui-call",
"line": 486,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Name",
"surface": "android",
"id": "native.android.00d9f5fe4abd05a0"
},
{
"kind": "ui-call",
"line": 496,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "$deviceModel · $appVersion",
"surface": "android",
"id": "native.android.d35345db50231440"
},
{
"kind": "ui-call",
"line": 508,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Default Assistant",
"surface": "android",
"id": "native.android.a3959f171202f031"
},
{
"kind": "ui-call",
"line": 544,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "MEDIA",
"surface": "android",
"id": "native.android.2949f2c21abc7e61"
},
{
"kind": "ui-call",
"line": 555,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Microphone",
"surface": "android",
"id": "native.android.d8116d29802a3122"
},
{
"kind": "conditional-branch",
"line": 558,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Granted",
"surface": "android",
"id": "native.android.4485058344287d1f"
},
{
"kind": "conditional-branch",
"line": 558,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Required for voice transcription.",
"surface": "android",
"id": "native.android.e2a32f3002f5dccf"
},
{
"kind": "ui-call",
"line": 585,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Camera",
"surface": "android",
"id": "native.android.72cb670153cdb9a2"
},
{
"kind": "ui-call",
"line": 586,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Photos and video clips (foreground only).",
"surface": "android",
"id": "native.android.29a57979453ac97e"
},
{
"kind": "ui-call",
"line": 594,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "NOTIFICATIONS",
"surface": "android",
"id": "native.android.893b84d511b75a97"
},
{
"kind": "ui-call",
"line": 605,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "System Notifications",
"surface": "android",
"id": "native.android.d316b20dd0309255"
},
{
"kind": "ui-call",
"line": 607,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Alerts and foreground service.",
"surface": "android",
"id": "native.android.ef0ca43d6ab9e92c"
},
{
"kind": "ui-call",
"line": 632,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Notification Listener Access",
"surface": "android",
"id": "native.android.30bcd3f0ebf33d13"
},
{
"kind": "ui-call",
"line": 634,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Required for `notifications.list`, `notifications.actions`, and forwarded notification events.",
"surface": "android",
"id": "native.android.f5c666d3707d9a31"
},
{
"kind": "conditional-branch",
"line": 646,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Enable",
"surface": "android",
"id": "native.android.5ad85205f857a25b"
},
{
"kind": "ui-call",
"line": 657,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "SMS",
"surface": "android",
"id": "native.android.581a2f434f14c5a2"
},
{
"kind": "ui-call",
"line": 659,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Grants Android SMS access. The Gateway must separately allow `sms.search` and, only if needed, `sms.send`.",
"surface": "android",
"id": "native.android.cba20518e9758043"
},
{
"kind": "ui-call",
"line": 696,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Forward Notification Events",
"surface": "android",
"id": "native.android.8c32ca1f11a93a25"
},
{
"kind": "ui-call",
"line": 735,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Package Filter: Allowlist",
"surface": "android",
"id": "native.android.0ca0ef510d5c07d3"
},
{
"kind": "ui-call",
"line": 737,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Only listed package IDs are forwarded.",
"surface": "android",
"id": "native.android.57bc87918c65bb9f"
},
{
"kind": "ui-call",
"line": 753,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Package Filter: Blocklist",
"surface": "android",
"id": "native.android.c20fb2c785da76b8"
},
{
"kind": "ui-call",
"line": 755,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "All packages except listed IDs are forwarded.",
"surface": "android",
"id": "native.android.7395a775f7d34568"
},
{
"kind": "conditional-branch",
"line": 778,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Close App Picker",
"surface": "android",
"id": "native.android.1ffc2ba152a64d9b"
},
{
"kind": "conditional-branch",
"line": 778,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Open App Picker",
"surface": "android",
"id": "native.android.f4ae672967a25eb4"
},
{
"kind": "ui-call",
"line": 797,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Search apps",
"surface": "android",
"id": "native.android.a06d9e470d1d453f"
},
{
"kind": "ui-call",
"line": 809,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Show System Apps",
"surface": "android",
"id": "native.android.b035f039d963c268"
},
{
"kind": "ui-call",
"line": 811,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Include Android/system packages in results.",
"surface": "android",
"id": "native.android.1600a4884e2a41da"
},
{
"kind": "ui-call",
"line": 850,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Quiet Hours",
"surface": "android",
"id": "native.android.e2ef6b37990a691c"
},
{
"kind": "ui-call",
"line": 852,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Suppress forwarding during a local time window.",
"surface": "android",
"id": "native.android.a4f770f835357f6c"
},
{
"kind": "ui-call",
"line": 874,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Quiet Start (HH:mm)",
"surface": "android",
"id": "native.android.e1869f00aa2189c7"
},
{
"kind": "ui-call",
"line": 882,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Use 24-hour HH:mm format, for example 22:00.",
"surface": "android",
"id": "native.android.d608320273495697"
},
{
"kind": "ui-call",
"line": 891,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Quiet End (HH:mm)",
"surface": "android",
"id": "native.android.ddcfd9ba56cc1aeb"
},
{
"kind": "ui-call",
"line": 899,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Use 24-hour HH:mm format, for example 07:00.",
"surface": "android",
"id": "native.android.4921ed94f99d6e63"
},
{
"kind": "ui-call",
"line": 918,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Save Quiet Hours",
"surface": "android",
"id": "native.android.556d0a3c15366f05"
},
{
"kind": "ui-call",
"line": 926,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Max Events / Minute",
"surface": "android",
"id": "native.android.63501cb40aae19ea"
},
{
"kind": "ui-call",
"line": 944,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Save Rate",
"surface": "android",
"id": "native.android.cad16090254466f2"
},
{
"kind": "ui-call",
"line": 953,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Route Session Key (optional)",
"surface": "android",
"id": "native.android.a08855b4ebbbd10a"
},
{
"kind": "ui-call",
"line": 960,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Blank keeps notification events on this device's default notification route. Set a key only to pin forwarding into a different session.",
"surface": "android",
"id": "native.android.db352fa71ce1e349"
},
{
"kind": "ui-call",
"line": 982,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Save Session Route",
"surface": "android",
"id": "native.android.6051f516f0825a19"
},
{
"kind": "ui-call",
"line": 990,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "DATA ACCESS",
"surface": "android",
"id": "native.android.908f014114bee723"
},
{
"kind": "ui-call",
"line": 1002,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Photos",
"surface": "android",
"id": "native.android.da5835468d89c5c7"
},
{
"kind": "ui-call",
"line": 1003,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Access recent photos.",
"surface": "android",
"id": "native.android.37cc59babec722da"
},
{
"kind": "ui-call",
"line": 1028,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Contacts",
"surface": "android",
"id": "native.android.7a5393132c797c82"
},
{
"kind": "ui-call",
"line": 1029,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Search and add contacts.",
"surface": "android",
"id": "native.android.2d3e44bab8c02b4e"
},
{
"kind": "ui-call",
"line": 1055,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Calendar",
"surface": "android",
"id": "native.android.be1a5598d23e8b99"
},
{
"kind": "ui-call",
"line": 1056,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Read and create events.",
"surface": "android",
"id": "native.android.dbd6257d7ed809a9"
},
{
"kind": "ui-call",
"line": 1083,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Call Log",
"surface": "android",
"id": "native.android.735879662e992dcd"
},
{
"kind": "ui-call",
"line": 1084,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Search recent call history.",
"surface": "android",
"id": "native.android.3c842261a945e43c"
},
{
"kind": "conditional-branch",
"line": 1098,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Grant",
"surface": "android",
"id": "native.android.16306ca8baec0e6e"
},
{
"kind": "conditional-branch",
"line": 1098,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Manage",
"surface": "android",
"id": "native.android.c5dd189c29c4983f"
},
{
"kind": "ui-call",
"line": 1110,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Motion",
"surface": "android",
"id": "native.android.475e97f2df757f8b"
},
{
"kind": "ui-call",
"line": 1111,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Track steps and activity.",
"surface": "android",
"id": "native.android.fa5092a818a770ab"
},
{
"kind": "ui-call",
"line": 1140,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "LOCATION",
"surface": "android",
"id": "native.android.304217db5e6b3192"
},
{
"kind": "ui-call",
"line": 1151,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Off",
"surface": "android",
"id": "native.android.474bf9e9dd0bb1ab"
},
{
"kind": "ui-call",
"line": 1152,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Disable location sharing.",
"surface": "android",
"id": "native.android.7d3e67457d28228a"
},
{
"kind": "ui-call",
"line": 1164,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "While Using",
"surface": "android",
"id": "native.android.5d60864f0652a0ab"
},
{
"kind": "ui-call",
"line": 1165,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Only while OpenClaw is open.",
"surface": "android",
"id": "native.android.3b2109ae1192e191"
},
{
"kind": "ui-call",
"line": 1177,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Precise Location",
"surface": "android",
"id": "native.android.b92944ae7d52f9e3"
},
{
"kind": "ui-call",
"line": 1178,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Use precise GPS when available.",
"surface": "android",
"id": "native.android.a8bc0f574f3563b0"
},
{
"kind": "ui-call",
"line": 1192,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "PREFERENCES",
"surface": "android",
"id": "native.android.8c3caf6577f67577"
},
{
"kind": "ui-call",
"line": 1203,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Prevent Sleep",
"surface": "android",
"id": "native.android.a88b03a8b3e7a353"
},
{
"kind": "ui-call",
"line": 1204,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Keep screen awake while open.",
"surface": "android",
"id": "native.android.a347983646f16109"
},
{
"kind": "ui-call",
"line": 1211,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Debug Canvas",
"surface": "android",
"id": "native.android.95b2da6c6a76bb18"
},
{
"kind": "ui-call",
"line": 1212,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt",
"source": "Show status overlay on canvas.",
"surface": "android",
"id": "native.android.9f346a677f3367e7"
},
{
"kind": "ui-named-argument",
"line": 437,
@ -5907,7 +5371,7 @@
},
{
"kind": "ui-named-argument",
"line": 300,
"line": 322,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Chat needs attention",
"surface": "android",
@ -5915,7 +5379,7 @@
},
{
"kind": "ui-named-argument",
"line": 482,
"line": 504,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "All",
"surface": "android",
@ -5923,7 +5387,7 @@
},
{
"kind": "ui-named-argument",
"line": 540,
"line": 562,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "OpenClaw",
"surface": "android",
@ -5931,7 +5395,7 @@
},
{
"kind": "ui-named-argument",
"line": 561,
"line": 583,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "New chat",
"surface": "android",
@ -5939,7 +5403,7 @@
},
{
"kind": "ui-named-argument",
"line": 566,
"line": 588,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "More new chat options",
"surface": "android",
@ -5947,7 +5411,7 @@
},
{
"kind": "ui-named-argument",
"line": 581,
"line": 603,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Refresh chat",
"surface": "android",
@ -5955,7 +5419,7 @@
},
{
"kind": "ui-named-argument",
"line": 584,
"line": 606,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Chat",
"surface": "android",
@ -5963,7 +5427,7 @@
},
{
"kind": "ui-named-argument",
"line": 732,
"line": 754,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Loading session",
"surface": "android",
@ -5971,7 +5435,7 @@
},
{
"kind": "ui-named-argument",
"line": 759,
"line": 781,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Jump to latest",
"surface": "android",
@ -5979,7 +5443,7 @@
},
{
"kind": "conditional-branch",
"line": 785,
"line": 807,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Ready when you are",
"surface": "android",
@ -5987,7 +5451,7 @@
},
{
"kind": "ui-named-argument",
"line": 816,
"line": 838,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Fix connection",
"surface": "android",
@ -5995,7 +5459,7 @@
},
{
"kind": "ui-named-argument",
"line": 817,
"line": 839,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Copy diagnostics",
"surface": "android",
@ -6003,7 +5467,7 @@
},
{
"kind": "conditional-branch",
"line": 1001,
"line": 1023,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Preparing audio…",
"surface": "android",
@ -6011,7 +5475,7 @@
},
{
"kind": "conditional-branch",
"line": 1001,
"line": 1023,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Speaking…",
"surface": "android",
@ -6019,7 +5483,7 @@
},
{
"kind": "ui-named-argument",
"line": 1022,
"line": 1044,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Tools running",
"surface": "android",
@ -6027,7 +5491,7 @@
},
{
"kind": "ui-named-argument",
"line": 1024,
"line": 1046,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "OpenClaw is working",
"surface": "android",
@ -6035,7 +5499,7 @@
},
{
"kind": "ui-named-argument",
"line": 1027,
"line": 1049,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "+${toolCalls.size - 4} more",
"surface": "android",
@ -6043,7 +5507,7 @@
},
{
"kind": "ui-named-argument",
"line": 1037,
"line": 1059,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Thinking",
"surface": "android",
@ -6051,7 +5515,7 @@
},
{
"kind": "ui-named-argument",
"line": 1038,
"line": 1060,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "OpenClaw is preparing a response.",
"surface": "android",
@ -6059,7 +5523,7 @@
},
{
"kind": "ui-named-argument",
"line": 1200,
"line": 1222,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Stop",
"surface": "android",
@ -6067,7 +5531,7 @@
},
{
"kind": "ui-named-argument",
"line": 1266,
"line": 1288,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Default",
"surface": "android",
@ -6075,7 +5539,7 @@
},
{
"kind": "conditional-branch",
"line": 1332,
"line": 1354,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Pin model",
"surface": "android",
@ -6083,7 +5547,7 @@
},
{
"kind": "conditional-branch",
"line": 1332,
"line": 1354,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Unpin model",
"surface": "android",
@ -6091,7 +5555,7 @@
},
{
"kind": "ui-named-argument",
"line": 1349,
"line": 1371,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "No commands found",
"surface": "android",
@ -6099,7 +5563,7 @@
},
{
"kind": "ui-named-argument",
"line": 1411,
"line": 1433,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Gateway offline",
"surface": "android",
@ -6107,7 +5571,7 @@
},
{
"kind": "ui-named-argument",
"line": 1511,
"line": 1533,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Attach image",
"surface": "android",
@ -6115,7 +5579,7 @@
},
{
"kind": "ui-named-argument",
"line": 1546,
"line": 1568,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Message OpenClaw",
"surface": "android",
@ -6123,7 +5587,7 @@
},
{
"kind": "ui-named-argument",
"line": 1562,
"line": 1584,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Open voice",
"surface": "android",
@ -6131,7 +5595,7 @@
},
{
"kind": "ui-named-argument",
"line": 1611,
"line": 1633,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Remove attachment",
"surface": "android",
@ -6139,7 +5603,7 @@
},
{
"kind": "conditional-branch",
"line": 1632,
"line": 1654,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Main",
"surface": "android",
@ -6147,7 +5611,7 @@
},
{
"kind": "conditional-branch",
"line": 1640,
"line": 1662,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "$emoji $name",
"surface": "android",
@ -6155,7 +5619,7 @@
},
{
"kind": "ui-named-argument",
"line": 1699,
"line": 1721,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Send",
"surface": "android",
@ -6163,20 +5627,12 @@
},
{
"kind": "conditional-branch",
"line": 1739,
"line": 1761,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}",
"surface": "android",
"id": "native.android.4e6c67df44d588d3"
},
{
"kind": "ui-named-argument",
"line": 297,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt",
"source": "CHAT ERROR",
"surface": "android",
"id": "native.android.826804c0189e03d0"
},
{
"kind": "ui-named-argument",
"line": 109,

View file

@ -8,7 +8,6 @@ import kotlinx.serialization.json.buildJsonObject
internal object AndroidScreenshotFixture {
const val mainSessionKey = "agent:main:node-screenshot"
const val primarySessionTitle = "Android release planning"
const val chatReadyText = "Ready when you are"
val agents =
listOf(

View file

@ -279,7 +279,6 @@ data class GatewayConnectionProblem(
val minimumProbeProtocol: Int? = null,
) {
val isPairingRequired: Boolean = code == "PAIRING_REQUIRED"
val isProtocolMismatch: Boolean = code == "PROTOCOL_MISMATCH"
val canAutoRetry: Boolean =
isPairingRequired &&
(

View file

@ -48,10 +48,6 @@ data class ToolDisplaySummary(
if (!detail.isNullOrBlank()) parts.add(detail)
return if (parts.isEmpty()) null else parts.joinToString(" · ")
}
/** Single-line fallback for compact tool rows that do not render detail separately. */
val summaryLine: String
get() = if (detailLine != null) "$emoji $label: $detailLine" else "$emoji $label"
}
/** Resolves tool-call names and args into user-facing Android display text. */

View file

@ -24,12 +24,6 @@ internal fun openClawAndroidVersionLabel(): String {
/** Normalizes blank gateway status text for display and diagnostics copy. */
internal fun gatewayStatusForDisplay(statusText: String): String = statusText.trim().ifEmpty { "Offline" }
/** Returns true when the status has enough signal to show diagnostics affordances. */
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower != "offline" && !lower.contains("connecting")
}
/** Resolves the best non-secret endpoint label available to diagnostics surfaces. */
internal fun gatewayDiagnosticsEndpoint(
remoteAddress: String?,

View file

@ -122,7 +122,6 @@ internal object MobileColorsAccessor {
// Keep these accessors while screens migrate to `MobileColorsAccessor.current`.
// Each getter must stay composable so callers always read the active theme.
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
internal val mobileBorder: Color @Composable get() = LocalMobileColors.current.border
internal val mobileBorderStrong: Color @Composable get() = LocalMobileColors.current.borderStrong
@ -132,8 +131,6 @@ internal val mobileTextTertiary: Color @Composable get() = LocalMobileColors.cur
internal val mobileAccent: Color @Composable get() = LocalMobileColors.current.accent
internal val mobileAccentSoft: Color @Composable get() = LocalMobileColors.current.accentSoft
internal val mobileAccentBorderStrong: Color @Composable get() = LocalMobileColors.current.accentBorderStrong
internal val mobileSuccess: Color @Composable get() = LocalMobileColors.current.success
internal val mobileSuccessSoft: Color @Composable get() = LocalMobileColors.current.successSoft
internal val mobileWarning: Color @Composable get() = LocalMobileColors.current.warning
internal val mobileWarningSoft: Color @Composable get() = LocalMobileColors.current.warningSoft
internal val mobileDanger: Color @Composable get() = LocalMobileColors.current.danger
@ -141,7 +138,6 @@ internal val mobileDangerSoft: Color @Composable get() = LocalMobileColors.curre
internal val mobileCodeBg: Color @Composable get() = LocalMobileColors.current.codeBg
internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current.codeText
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
// Build the page backdrop from semantic surfaces so light/dark palettes keep
// their contrast relationship without duplicating raw color stops.
@ -165,15 +161,6 @@ internal val mobileFontFamily =
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
internal val mobileTitle1 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 30.sp,
letterSpacing = (-0.5).sp,
)
internal val mobileHeadline =
TextStyle(
fontFamily = mobileFontFamily,

View file

@ -106,6 +106,28 @@ import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
/** Returns a pending assistant prompt only when chat can accept it immediately. */
internal fun resolvePendingAssistantAutoSend(
pendingPrompt: String?,
healthOk: Boolean,
pendingRunCount: Int,
): String? {
val prompt = pendingPrompt?.trim()?.ifEmpty { null } ?: return null
if (!healthOk || pendingRunCount > 0) return null
return prompt
}
/** Chooses the session key to load for initial chat hydration, if any. */
internal fun resolveInitialChatLoadSessionKey(
sessionKey: String,
mainSessionKey: String,
): String? {
val current = sessionKey.trim()
val main = mainSessionKey.trim().ifEmpty { "main" }
if (current.isNotEmpty() && current != "main" && current != main) return null
return main
}
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
@Composable
fun ChatScreen(

View file

@ -1,304 +0,0 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileAccentBorderStrong
import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCaption2
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileDanger
import ai.openclaw.app.ui.mobileDangerSoft
import ai.openclaw.app.ui.mobileText
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Returns a pending assistant prompt only when chat can accept it immediately. */
internal fun resolvePendingAssistantAutoSend(
pendingPrompt: String?,
healthOk: Boolean,
pendingRunCount: Int,
): String? {
val prompt = pendingPrompt?.trim()?.ifEmpty { null } ?: return null
if (!healthOk || pendingRunCount > 0) return null
return prompt
}
/** Chooses the session key to load for initial chat hydration, if any. */
internal fun resolveInitialChatLoadSessionKey(
sessionKey: String,
mainSessionKey: String,
): String? {
val current = sessionKey.trim()
val main = mainSessionKey.trim().ifEmpty { "main" }
if (current.isNotEmpty() && current != "main" && current != main) return null
return main
}
/** Main Android chat sheet content: session picker, message list, and composer. */
@Composable
fun ChatSheetContent(viewModel: MainViewModel) {
val messages by viewModel.chatMessages.collectAsState()
val historyLoading by viewModel.chatHistoryLoading.collectAsState()
val errorText by viewModel.chatError.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
val selectedModelRef by viewModel.chatSelectedModelRef.collectAsState()
// Gate from the controller's agent-scoped catalog — the same source the send gate reads —
// so the visible control can never disagree with what sends actually carry.
val modelCatalog by viewModel.chatModelCatalog.collectAsState()
val thinkingSupported = thinkingSupportedForSelection(selectedModelRef, modelCatalog)
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val chatCommands by viewModel.chatCommands.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
val assistantAutoSendInFlight by viewModel.assistantAutoSendInFlight.collectAsState()
val outboxItems by viewModel.chatOutboxItems.collectAsState()
val messageSpeechState by viewModel.chatMessageSpeech.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val gatewayOffline = !gatewayConnectionDisplay.isConnected
val micEnabled by viewModel.micEnabled.collectAsState()
val micIsListening by viewModel.micIsListening.collectAsState()
val micCooldown by viewModel.micCooldown.collectAsState()
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
val talkModeListening by viewModel.talkModeListening.collectAsState()
DisposableEffect(viewModel) {
onDispose(viewModel::stopChatMessageSpeech)
}
LaunchedEffect(Unit) {
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
if (loadSessionKey != null) {
viewModel.loadChat(loadSessionKey)
}
viewModel.refreshChatCommands()
}
LaunchedEffect(pendingAssistantAutoSend, assistantAutoSendInFlight, healthOk, pendingRunCount, thinkingLevel) {
// Assistant-launch prompts should wait for a healthy idle chat so they do
// not race an already-running turn.
if (!healthOk) return@LaunchedEffect
val prompt =
resolvePendingAssistantAutoSend(
pendingPrompt = pendingAssistantAutoSend,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
) ?: return@LaunchedEffect
viewModel.dispatchPendingAssistantAutoSend(
pendingPrompt = prompt,
thinking = thinkingLevel,
)
}
val context = LocalContext.current
val resolver = context.contentResolver
val scope = rememberCoroutineScope()
val attachments = remember { mutableStateListOf<PendingAttachment>() }
val micCaptureActive = micEnabled || micIsListening || micCooldown || talkModeEnabled || talkModeListening
val voiceNoteRecorder =
rememberVoiceNoteRecorderController(
viewModel = viewModel,
onFinished = attachments::add,
)
val voiceNoteState by voiceNoteRecorder.state.collectAsState()
val voiceNoteElapsedMs by voiceNoteRecorder.elapsedMs.collectAsState()
val pickImages =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
// Bound both count and encoded size before attachments enter Compose
// state; sending uses these already-compressed payloads directly.
val next =
uris.take(8).mapNotNull { uri ->
try {
loadSizedImageAttachment(resolver, uri)
} catch (_: Throwable) {
null
}
}
withContext(Dispatchers.Main) {
attachments.addAll(next)
}
}
}
Column(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ChatThreadSelector(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
onSelectSession = { key -> viewModel.switchChatSession(key) },
)
if (!errorText.isNullOrBlank()) {
ChatErrorRail(errorText = errorText!!)
}
ChatMessageListCard(
sessionKey = sessionKey,
messages = messages,
historyLoading = historyLoading,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText,
healthOk = healthOk,
gatewayOffline = gatewayOffline,
modifier = Modifier.weight(1f, fill = true),
outboxItems =
outboxItemsForSession(
items = outboxItems,
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
),
onRetryOutbox = viewModel::retryChatOutboxCommand,
onDeleteOutbox = viewModel::deleteChatOutboxCommand,
onReplyMessage = viewModel::setChatReplyDraft,
speechState = messageSpeechState,
onToggleListen = viewModel::toggleChatMessageSpeech,
)
Row(modifier = Modifier.fillMaxWidth().imePadding()) {
ChatComposer(
draftText = chatDraft,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
thinkingSupported = thinkingSupported,
pendingRunCount = pendingRunCount,
commands = chatCommands,
attachments = attachments,
onDraftApplied = viewModel::clearChatDraft,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
voiceNoteState = voiceNoteState,
voiceNoteElapsedMs = voiceNoteElapsedMs,
recordVoiceNoteEnabled = pendingRunCount == 0 && !micCaptureActive,
onStartVoiceNote = { scope.launch { voiceNoteRecorder.start() } },
onCancelVoiceNote = voiceNoteRecorder::cancel,
onFinishVoiceNote = voiceNoteRecorder::finish,
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onRefresh = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 200)
viewModel.refreshChatCommands()
},
onAbort = { viewModel.abortChat() },
onSend = { text ->
val outgoing = attachments.map(PendingAttachment::toOutgoingAttachment)
val pendingAttachments = attachments.toList()
attachments.clear()
val accepted = viewModel.sendChatAwaitAcceptance(message = text, thinking = thinkingLevel, attachments = outgoing)
if (!accepted && attachments.isEmpty()) {
// Refused sends must not silently drop selected attachments either.
attachments.addAll(pendingAttachments)
}
accepted
},
)
}
}
}
@Composable
private fun ChatThreadSelector(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
onSelectSession: (String) -> Unit,
) {
val sessionOptions =
remember(sessionKey, sessions, mainSessionKey) {
resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (entry in sessionOptions) {
val active = entry.key == sessionKey
Surface(
onClick = { onSelectSession(entry.key) },
shape = RoundedCornerShape(14.dp),
color = if (active) mobileAccent else mobileCardSurface,
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Text(
text = friendlySessionName(entry.displayName ?: entry.key),
style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold),
color = if (active) Color.White else mobileText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
}
@Composable
private fun ChatErrorRail(errorText: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = mobileDangerSoft,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = "CHAT ERROR",
style = mobileCaption2.copy(letterSpacing = 0.6.sp),
color = mobileDanger,
)
Text(text = errorText, style = mobileCallout, color = mobileText)
}
}
}

View file

@ -3,7 +3,7 @@ package ai.openclaw.app.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class SettingsSheetNotificationAppsTest {
class SettingsScreensNotificationAppsTest {
@Test
fun resolveNotificationCandidatePackages_keepsConfiguredPackagesVisible() {
val packages =

View file

@ -7,7 +7,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSheetContentTest {
class ChatScreenTest {
@Test
fun agentChipUsesEmojiAndFallsBackToId() {
assertEquals(

View file

@ -104,10 +104,10 @@ Hard rule: avoid ultra-thin weights on light backgrounds.
## 11. Source Of Truth
- `app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt`
- `app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt`
- `app/src/main/java/ai/openclaw/android/ui/RootScreen.kt`
- `app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt`
- `app/src/main/java/ai/openclaw/android/MainViewModel.kt`
- `app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt`
- `app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt`
- `app/src/main/java/ai/openclaw/app/ui/RootScreen.kt`
- `app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt`
- `app/src/main/java/ai/openclaw/app/MainViewModel.kt`
If style and implementation diverge, update both in the same change.

View file

@ -261,7 +261,7 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi
Persistent provider, model, voice, transport, reasoning effort, exact VAD threshold, silence duration, and prefix padding defaults live in **Settings → Communications → Talk**; changing them requires `operator.admin` access. Configuring Gateway relay forces the backend relay path; configuring WebRTC keeps the session client-owned and fails instead of silently falling back to relay if the provider cannot create a browser session.
The Talk control itself is the microphone button in the composer toolbar. Its caret lists **System default** and every microphone exposed by the browser, including USB, Bluetooth, and virtual inputs. The selected device ID stays browser-local and is never sent to the Gateway; if that exact device disappears, Talk asks you to choose another input instead of silently recording from a different microphone. When Talk starts, the composer status row shows `Connecting Talk...`, then `Talk live` while audio is connected, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `talk.client.toolCall`.
The Talk control itself is the microphone button in the composer toolbar. Its caret lists **System default** and every microphone exposed by the browser, including USB, Bluetooth, and virtual inputs. The selected device ID stays browser-local and is never sent to the Gateway; if that exact device disappears, Talk asks you to choose another input instead of silently recording from a different microphone. While Talk is live, the microphone button becomes a pill showing the live input-level meter; clicking it stops voice input, and hovering it reveals the stop glyph. Screen readers announce `Connecting voice input...`, `Listening...`, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `talk.client.toolCall`. Stopping a running agent response stays a separate square **Stop** control next to the pill.
Maintainer live smoke: `OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts` verifies the OpenAI backend WebSocket bridge, OpenAI browser WebRTC SDP exchange, Google Live constrained-token browser WebSocket setup, and the Gateway relay browser adapter with fake microphone media. The command prints provider status only and does not log secrets.

View file

@ -3,6 +3,7 @@ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { beforeEach, describe, expect, it, vi } from "vitest";
import canvasPlugin from "./index.js";
import { SHOW_WIDGET_REQUIRED_CLIENT_CAPS } from "./src/tool-schema.js";
const mocks = vi.hoisted(() => {
const httpHandler = {
@ -11,6 +12,7 @@ const mocks = vi.hoisted(() => {
close: vi.fn(async () => {}),
};
const toolExecute = vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] }));
const widgetToolExecute = vi.fn(async () => ({ content: [{ type: "text", text: "widget" }] }));
return {
httpHandler,
createCanvasHttpRouteHandler: vi.fn(() => httpHandler),
@ -25,6 +27,14 @@ const mocks = vi.hoisted(() => {
parameters: {},
execute: toolExecute,
})),
widgetToolExecute,
createShowWidgetTool: vi.fn(() => ({
label: "Show Widget",
name: "show_widget",
description: "Show Widget",
parameters: {},
execute: widgetToolExecute,
})),
};
});
@ -45,11 +55,18 @@ vi.mock("./src/tool.js", () => ({
createCanvasTool: mocks.createCanvasTool,
}));
vi.mock("./src/widget-tool.js", () => ({
createShowWidgetTool: mocks.createShowWidgetTool,
}));
function registerCanvas() {
const routes: Array<Parameters<OpenClawPluginApi["registerHttpRoute"]>[0]> = [];
const services: Array<Parameters<OpenClawPluginApi["registerService"]>[0]> = [];
const resolvers: Array<Parameters<OpenClawPluginApi["registerHostedMediaResolver"]>[0]> = [];
const tools: Array<Parameters<OpenClawPluginApi["registerTool"]>[0]> = [];
const tools: Array<{
tool: Parameters<OpenClawPluginApi["registerTool"]>[0];
opts: Parameters<OpenClawPluginApi["registerTool"]>[1];
}> = [];
const cliFeatures: Array<{
registrar: Parameters<OpenClawPluginApi["registerNodeCliFeature"]>[0];
opts: Parameters<OpenClawPluginApi["registerNodeCliFeature"]>[1];
@ -62,7 +79,7 @@ function registerCanvas() {
registerHttpRoute: (route) => routes.push(route),
registerService: (service) => services.push(service),
registerHostedMediaResolver: (resolver) => resolvers.push(resolver),
registerTool: (tool) => tools.push(tool),
registerTool: (tool, opts) => tools.push({ tool, opts }),
registerNodeCliFeature: (registrar, opts) => cliFeatures.push({ registrar, opts }),
registerNodeInvokePolicy: vi.fn(),
}),
@ -97,11 +114,13 @@ describe("Canvas plugin entry", () => {
const { resolvers, tools, cliFeatures } = registerCanvas();
expect(resolvers).toHaveLength(1);
expect(tools).toHaveLength(1);
expect(tools).toHaveLength(2);
expect(tools.map(({ opts }) => opts?.name)).toEqual([undefined, "show_widget"]);
expect(cliFeatures).toHaveLength(1);
expect(mocks.resolveCanvasHttpPathToLocalPath).not.toHaveBeenCalled();
expect(mocks.createDefaultCanvasCliDependencies).not.toHaveBeenCalled();
expect(mocks.createCanvasTool).not.toHaveBeenCalled();
expect(mocks.createShowWidgetTool).not.toHaveBeenCalled();
await expect(resolvers[0]?.("/__openclaw__/canvas/documents/id/index.html")).resolves.toBe(
"/tmp/canvas-asset",
@ -118,21 +137,42 @@ describe("Canvas plugin entry", () => {
expect(mocks.createDefaultCanvasCliDependencies).toHaveBeenCalledTimes(1);
expect(mocks.registerNodesCanvasCommands).toHaveBeenCalledTimes(1);
const toolFactory = tools[0];
expect(typeof toolFactory).toBe("function");
const tool = (toolFactory as Exclude<typeof toolFactory, AnyAgentTool>)({
config: {},
workspaceDir: "/tmp/workspace",
const registeredTools = tools.map(({ tool: toolFactory }) => {
expect(typeof toolFactory).toBe("function");
const tool = (toolFactory as Exclude<typeof toolFactory, AnyAgentTool>)({
config: {},
workspaceDir: "/tmp/workspace",
sessionId: "session-1",
agentId: "agent-1",
});
expect(Array.isArray(tool)).toBe(false);
return tool as AnyAgentTool;
});
expect(Array.isArray(tool)).toBe(false);
expect((tool as AnyAgentTool).name).toBe("canvas");
expect(registeredTools.map((tool) => tool.name)).toEqual(["canvas", "show_widget"]);
expect(registeredTools[1]?.requiredClientCaps).toEqual(SHOW_WIDGET_REQUIRED_CLIENT_CAPS);
expect(mocks.createCanvasTool).not.toHaveBeenCalled();
expect(mocks.createShowWidgetTool).not.toHaveBeenCalled();
await (tool as AnyAgentTool).execute("tool-call", { action: "hide" });
const [canvasTool, showWidgetTool] = registeredTools;
await canvasTool?.execute("tool-call", { action: "hide" });
expect(mocks.createCanvasTool).toHaveBeenCalledWith({
config: {},
workspaceDir: "/tmp/workspace",
});
expect(mocks.toolExecute).toHaveBeenCalledWith("tool-call", { action: "hide" });
await showWidgetTool?.execute("widget-call", {
title: "Status",
widget_code: "<p>ready</p>",
});
expect(mocks.createShowWidgetTool).toHaveBeenCalledWith({
config: {},
sessionId: "session-1",
agentId: "agent-1",
});
expect(mocks.widgetToolExecute).toHaveBeenCalledWith("widget-call", {
title: "Status",
widget_code: "<p>ready</p>",
});
});
});

View file

@ -231,6 +231,68 @@ describe("codex doctor contract", () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("reports unresolved-owner binding sidecars as notices after importing conversation binding", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
const transcriptPath = path.join(sessionsDir, "orphan.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
sidecarPath,
JSON.stringify({
schemaVersion: 2,
threadId: "thread-orphan",
sessionFile: transcriptPath,
updatedAt: "2026-01-01T00:00:00.000Z",
pluginAppPolicyContext: {
fingerprint: "policy-1",
apps: {},
pluginAppIds: {},
},
}),
"utf8",
);
const params = {
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
};
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
const result = await migration.migrateLegacyState(params);
const canonicalSidecarPath = await fs.realpath(sidecarPath);
expect(result.warnings).toStrictEqual([]);
expect(result.notices).toStrictEqual([
`Left Codex binding sidecar in place after importing its conversation binding because its session owner could not be resolved: ${canonicalSidecarPath}`,
]);
expect(result.changes).toContain(
"Migrated 1 safe Codex app-server binding row(s) to plugin state; retained legacy sidecars needing review",
);
await expect(fs.access(sidecarPath)).resolves.toBeUndefined();
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await expect(
store.lookup(
bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(transcriptPath),
}),
),
).resolves.toMatchObject({ state: "active", binding: { threadId: "thread-orphan" } });
await fs.rm(stateDir, { recursive: true, force: true });
});
it("does not scan above stateDir when a session store sits at its parent", async () => {
const outerDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-outer-"));
const stateDir = path.join(outerDir, "state");

View file

@ -280,6 +280,7 @@ describe("Codex app-server dynamic tool build", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.clientCaps = ["tool-events", "inline-widgets"];
let receivedClientCaps: string[] | undefined;
setOpenClawCodingToolsFactoryForTests((options) => {

View file

@ -57,6 +57,7 @@ type SourceMigrationResult = {
archived: boolean;
importedKeys: number;
warning?: string;
notice?: string;
};
// Keep the doctor contract graph independent from the full Codex runtime.
@ -430,7 +431,7 @@ async function migrateSource(
return {
archived: false,
importedKeys,
warning: `Left Codex binding sidecar in place after importing its conversation binding because its session owner could not be resolved: ${source.sidecarPath}`,
notice: `Left Codex binding sidecar in place after importing its conversation binding because its session owner could not be resolved: ${source.sidecarPath}`,
};
}
const ownershipWarning = await recordSessionOwner(owner);
@ -442,11 +443,7 @@ async function migrateSource(
return retain(`canonical plugin state changed at ${entry.key}`);
}
}
const archivePath = `${source.sidecarPath}.migrated`;
if (await pathExists(archivePath)) {
return retain(`its archive already exists at ${archivePath}`);
}
await fs.rename(source.sidecarPath, archivePath);
await archiveBindingSidecar(source.sidecarPath);
return { archived: true, importedKeys };
});
} catch (error) {
@ -527,6 +524,32 @@ async function pathExists(filePath: string): Promise<boolean> {
}
}
async function firstFreeArchivePath(sourcePath: string): Promise<string> {
for (let index = 2; ; index++) {
const candidate = `${sourcePath}.migrated.${index}`;
if (!(await pathExists(candidate))) {
return candidate;
}
}
}
async function archiveBindingSidecar(sourcePath: string): Promise<void> {
const archivePath = `${sourcePath}.migrated`;
if (await pathExists(archivePath)) {
const [sourceBytes, archiveBytes] = await Promise.all([
fs.readFile(sourcePath),
fs.readFile(archivePath),
]);
if (sourceBytes.equals(archiveBytes)) {
await fs.rm(sourcePath, { force: true });
return;
}
await fs.rename(sourcePath, await firstFreeArchivePath(sourcePath));
return;
}
await fs.rename(sourcePath, archivePath);
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "codex-app-server-sidecars-to-plugin-state",
@ -544,6 +567,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const notices: string[] = [];
const { sources, surfaces } = await collectLegacyBindingSources(params);
if (sources.length === 0) {
return { changes, warnings };
@ -563,6 +587,9 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
if (result.warning) {
warnings.push(result.warning);
}
if (result.notice) {
notices.push(result.notice);
}
if (result.archived) {
migrated++;
} else {
@ -579,7 +606,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
`Migrated ${partialImports} safe Codex app-server binding row(s) to plugin state; retained legacy sidecars needing review`,
);
}
return { changes, warnings };
return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings };
},
},
];

View file

@ -1133,11 +1133,40 @@ describe("google transport stream", () => {
expect(googleAuthMock).toHaveBeenCalledWith({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
clientOptions: { transporterOptions: { timeout: 30_000 } },
});
expect(googleAuthGetAccessTokenMock).toHaveBeenCalledTimes(1);
expect(tokenFetchMock).not.toHaveBeenCalled();
});
it("bounds google-auth-library ADC token resolution at the Vertex owner", async () => {
const tempDir = await mkdtemp(
path.join(os.tmpdir(), "openclaw-google-vertex-authlib-timeout-"),
);
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
vi.stubEnv("HOME", path.join(tempDir, "home"));
vi.stubEnv("APPDATA", "");
vi.useFakeTimers();
googleAuthGetAccessTokenMock
.mockReturnValueOnce(new Promise(() => {}))
.mockResolvedValueOnce("ya29.recovered-token");
const pendingRefresh = resolveGoogleVertexAuthorizedUserHeaders(vi.fn());
const refreshError = pendingRefresh.catch((error: unknown) => error);
await vi.waitFor(() => expect(googleAuthGetAccessTokenMock).toHaveBeenCalledOnce());
await vi.advanceTimersByTimeAsync(30_000);
await expect(refreshError).resolves.toMatchObject({
name: "TimeoutError",
message: "request timed out",
});
await expect(resolveGoogleVertexAuthorizedUserHeaders(vi.fn())).resolves.toEqual({
Authorization: "Bearer ya29.recovered-token",
});
expect(googleAuthMock).toHaveBeenCalledTimes(2);
expect(googleAuthGetAccessTokenMock).toHaveBeenCalledTimes(2);
});
it("does not cache google-auth ADC tokens when fallback expiry would exceed Date range", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-expiry-"));
vi.useFakeTimers();
@ -1314,6 +1343,56 @@ describe("google transport stream", () => {
expect(result.content).toEqual([{ type: "text", text: "ok" }]);
});
it("times out an authorized_user ADC token refresh", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-timeout-"));
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
await writeFile(
credentialsPath,
JSON.stringify({
type: "authorized_user",
client_id: "client-id",
client_secret: "client-secret",
refresh_token: "timeout-refresh-token",
}),
"utf8",
);
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath);
vi.useFakeTimers();
let observedSignal: AbortSignal | undefined;
const tokenFetchMock = vi.fn((_input: string | URL | Request, init?: RequestInit) => {
const signal = init?.signal;
if (!signal) {
throw new Error("expected token refresh deadline signal");
}
observedSignal = signal;
const body = new ReadableStream<Uint8Array>({
start(controller) {
signal.addEventListener("abort", () => controller.error(signal.reason), { once: true });
},
});
return Promise.resolve(new Response(body, { status: 200 }));
});
const pendingRefresh = resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock);
// Attach the rejection handler before advancing fake time so the expected
// timeout cannot surface as an unhandled rejection between timer ticks.
const refreshError = pendingRefresh.catch((error: unknown) => error);
await vi.waitFor(() => expect(tokenFetchMock).toHaveBeenCalledOnce());
const signal = observedSignal;
if (!signal) {
throw new Error("expected token refresh deadline signal");
}
expect(signal.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(30_000);
expect(signal.aborted).toBe(true);
await expect(refreshError).resolves.toMatchObject({
name: "TimeoutError",
message: "request timed out",
});
});
it("refreshes authorized_user ADC from a compressed token response", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-gzip-"));
const credentialsPath = path.join(tempDir, "application_default_credentials.json");

View file

@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { gunzipSync } from "node:zlib";
import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
@ -11,6 +12,7 @@ import {
} from "openclaw/plugin-sdk/number-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
type GoogleAuthorizedUserCredentials = {
type: "authorized_user";
@ -41,6 +43,7 @@ type GoogleOauthTokenResponsePayload = {
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
const GOOGLE_VERTEX_OAUTH_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
const GOOGLE_VERTEX_ADC_TOKEN_REFRESH_TIMEOUT_MS = 30_000;
// Hold tokens slightly less long than reported expiry (Google's recommendation
// is a 60s buffer) so we don't ship a request that's already revoked when it
// leaves the gateway.
@ -243,12 +246,26 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
refresh_token: refreshToken,
grant_type: "refresh_token",
});
const response = await (params.fetchImpl ?? fetch)(GOOGLE_OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
const { signal, cleanup } = buildTimeoutAbortSignal({
timeoutMs: GOOGLE_VERTEX_ADC_TOKEN_REFRESH_TIMEOUT_MS,
operation: "google-vertex-adc-token-refresh",
url: GOOGLE_OAUTH_TOKEN_URL,
});
const payload = await readGoogleOauthTokenResponsePayload(response);
let response: Response;
let payload: GoogleOauthTokenResponsePayload | undefined;
try {
response = await (params.fetchImpl ?? fetch)(GOOGLE_OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal,
});
// Keep the request deadline active through body consumption. Fetch resolves
// at headers, so cleanup here would leave a stalled token body unbounded.
payload = await readGoogleOauthTokenResponsePayload(response);
} finally {
cleanup();
}
if (!response.ok) {
const description = normalizeOptionalString(payload?.error_description);
const code = normalizeOptionalString(payload?.error);
@ -345,18 +362,42 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
// It also caches tokens internally and refreshes before expiry.
return new GoogleAuth({
scopes: [GOOGLE_VERTEX_OAUTH_SCOPE],
// Best-effort cancellation for clients that use the shared transporter.
// WIF STS and GCE metadata need the owner-level deadline below.
clientOptions: {
transporterOptions: { timeout: GOOGLE_VERTEX_ADC_TOKEN_REFRESH_TIMEOUT_MS },
},
});
}),
};
}
const auth = await cachedGoogleAuthClient.promise;
const authClient = cachedGoogleAuthClient;
const auth = await authClient.promise;
const cached = cachedGoogleVertexAdcToken;
if (cached && isGoogleVertexTokenFresh(cached.expiresAtMs)) {
return cached.token;
}
const token = await auth.getAccessToken();
// Some google-auth-library ADC implementations bypass the configured Gaxios
// transporter, so this owner-level deadline also bounds STS and metadata paths.
let token: string | null | undefined;
try {
token = await withTimeout(auth.getAccessToken(), GOOGLE_VERTEX_ADC_TOKEN_REFRESH_TIMEOUT_MS, {
createError: () => new DOMException("request timed out", "TimeoutError"),
});
} catch (error) {
// The dependency coalesces in-flight refreshes. Drop only this timed-out
// client so a recovered identity endpoint gets a fresh attempt next time.
if (
error instanceof DOMException &&
error.name === "TimeoutError" &&
cachedGoogleAuthClient === authClient
) {
cachedGoogleAuthClient = undefined;
}
throw error;
}
const normalized = normalizeOptionalString(token);
if (!normalized) {
throw new Error(

View file

@ -1,11 +1,10 @@
// Googlechat API module exposes the plugin public contract.
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
parseMediaContentLength,
readResponseTextSnippet,
} from "openclaw/plugin-sdk/media-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
@ -15,9 +14,46 @@ import type { GoogleChatCardV2, GoogleChatReaction, GoogleChatSpace } from "./ty
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
const GOOGLECHAT_API_TIMEOUT_MS = 30_000;
const GOOGLECHAT_MEDIA_TIMEOUT_GRACE_MS = 30_000;
const GOOGLECHAT_MEDIA_MIN_BYTES_PER_SECOND = 256 * 1024;
const GOOGLECHAT_MEDIA_MAX_TIMEOUT_MS = 15 * 60_000;
const GOOGLECHAT_RESPONSE_READ_IDLE_TIMEOUT_MS = 30_000;
const GOOGLECHAT_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
const GOOGLECHAT_ERROR_BODY_MAX_BYTES = 16 * 1024;
function resolveGoogleChatMediaTimeoutMs(maxBytes?: number): number {
if (!maxBytes) {
return GOOGLECHAT_MEDIA_MAX_TIMEOUT_MS;
}
const transferMs = Math.ceil((maxBytes / GOOGLECHAT_MEDIA_MIN_BYTES_PER_SECOND) * 1000);
return Math.min(GOOGLECHAT_MEDIA_TIMEOUT_GRACE_MS + transferMs, GOOGLECHAT_MEDIA_MAX_TIMEOUT_MS);
}
async function readGoogleChatJsonResponse<T>(response: Response, label: string): Promise<T> {
return readProviderJsonResponse<T>(response, label);
const bytes = await readResponseWithLimit(response, GOOGLECHAT_JSON_RESPONSE_MAX_BYTES, {
chunkTimeoutMs: GOOGLECHAT_RESPONSE_READ_IDLE_TIMEOUT_MS,
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`${label}: response body stalled after ${chunkTimeoutMs}ms`),
onOverflow: ({ maxBytes }) => new Error(`${label}: JSON response exceeds ${maxBytes} bytes`),
});
try {
return JSON.parse(new TextDecoder().decode(bytes)) as T;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
}
async function readGoogleChatErrorResponse(response: Response, label: string): Promise<string> {
return (
(await readResponseTextSnippet(response, {
maxBytes: GOOGLECHAT_ERROR_BODY_MAX_BYTES,
maxChars: GOOGLECHAT_ERROR_BODY_MAX_BYTES,
chunkTimeoutMs: GOOGLECHAT_RESPONSE_READ_IDLE_TIMEOUT_MS,
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`${label} error response stalled after ${chunkTimeoutMs}ms`),
})) ?? ""
);
}
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
@ -33,6 +69,7 @@ async function withGoogleChatResponse<T>(params: {
init?: RequestInit;
auditContext: string;
errorPrefix?: string;
timeoutMs?: number;
handleResponse: (response: Response) => Promise<T>;
}): Promise<T> {
const {
@ -41,6 +78,7 @@ async function withGoogleChatResponse<T>(params: {
init,
auditContext,
errorPrefix = "Google Chat API",
timeoutMs = GOOGLECHAT_API_TIMEOUT_MS,
handleResponse,
} = params;
const token = await getGoogleChatAccessToken(account);
@ -54,10 +92,11 @@ async function withGoogleChatResponse<T>(params: {
},
},
auditContext,
timeoutMs,
});
try {
if (!response.ok) {
const text = await readResponseTextLimited(response).catch(() => "");
const text = await readGoogleChatErrorResponse(response, errorPrefix);
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
}
return await handleResponse(response);
@ -112,6 +151,9 @@ async function fetchBuffer(
url,
init,
auditContext: "googlechat.api.buffer",
// Media gets transfer time proportional to its accepted size, while a silent
// response body is still bounded independently below.
timeoutMs: resolveGoogleChatMediaTimeoutMs(options?.maxBytes),
handleResponse: async (res) => {
const maxBytes = options?.maxBytes;
const lengthHeader = res.headers.get("content-length");
@ -127,6 +169,7 @@ async function fetchBuffer(
return { buffer, contentType };
}
const buffer = await readResponseWithLimit(res, maxBytes, {
chunkTimeoutMs: GOOGLECHAT_RESPONSE_READ_IDLE_TIMEOUT_MS,
onOverflow: () => new Error(`Google Chat media exceeds max bytes (${maxBytes})`),
});
const contentType = res.headers.get("content-type") ?? undefined;
@ -255,6 +298,7 @@ export async function uploadGoogleChatAttachment(params: {
},
auditContext: "googlechat.upload",
errorPrefix: "Google Chat upload",
timeoutMs: resolveGoogleChatMediaTimeoutMs(body.length),
handleResponse: async (response) =>
await readGoogleChatJsonResponse<{
attachmentDataRef?: { attachmentUploadToken?: string };

View file

@ -1,7 +1,12 @@
// Googlechat tests cover targets plugin behavior.
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
import {
downloadGoogleChatMedia,
sendGoogleChatMessage,
updateGoogleChatMessage,
uploadGoogleChatAttachment,
} from "./api.js";
import {
clearGoogleChatApprovalCardBindingsForTest,
registerGoogleChatManualApprovalFollowupSuppression,
@ -20,10 +25,12 @@ const mocks = vi.hoisted(() => ({
buildHostnameAllowlistPolicyFromSuffixAllowlist: vi.fn((hosts: string[]) => ({
hostnameAllowlist: hosts,
})),
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
response: await fetch(params.url, params.init),
release: async () => {},
})),
fetchWithSsrFGuard: vi.fn(
async (params: { url: string; init?: RequestInit; timeoutMs?: number }) => ({
response: await fetch(params.url, params.init),
release: async () => {},
}),
),
googleAuthCtor: vi.fn(),
gaxiosCtor: vi.fn(),
getAccessToken: vi.fn().mockResolvedValue({ token: "access-token" }),
@ -115,6 +122,15 @@ function stubSuccessfulSend(name: string, threadName?: string) {
return fetchMock;
}
function createStalledResponse(status = 200): Response {
return new Response(
new ReadableStream({
start() {},
}),
{ status },
);
}
async function expectDownloadToRejectForResponse(
response: Response,
expected: string | RegExp = /max bytes/i,
@ -133,6 +149,14 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0
return call[argIndex];
}
function lastGuardedFetchOptions(): { timeoutMs?: number } {
const call = mocks.fetchWithSsrFGuard.mock.calls.at(-1);
if (!call) {
throw new Error("Expected guarded fetch call");
}
return call[0] as { timeoutMs?: number };
}
describe("normalizeGoogleChatTarget", () => {
it("normalizes provider prefixes", () => {
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
@ -265,6 +289,7 @@ describe("downloadGoogleChatMedia", () => {
authTesting.resetGoogleChatAuthForTests();
mocks.fetchWithSsrFGuard.mockClear();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("rejects when content-length exceeds max bytes", async () => {
@ -279,6 +304,7 @@ describe("downloadGoogleChatMedia", () => {
headers: { "content-length": "50", "content-type": "application/octet-stream" },
});
await expectDownloadToRejectForResponse(response);
expect(lastGuardedFetchOptions().timeoutMs).toBe(30_001);
});
it("rejects malformed content-length before reading media", async () => {
@ -315,6 +341,73 @@ describe("downloadGoogleChatMedia", () => {
});
await expectDownloadToRejectForResponse(response);
});
it("cancels a media body that stops producing chunks", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createStalledResponse()));
const result = expect(
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
).rejects.toThrow("Media download stalled: no data received for 30000ms");
await vi.advanceTimersByTimeAsync(30_001);
await result;
});
it("cancels a stalled media error body", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createStalledResponse(500)));
const result = expect(
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
).rejects.toThrow("Google Chat API error response stalled after 30000ms");
await vi.advanceTimersByTimeAsync(30_001);
await result;
});
});
describe("uploadGoogleChatAttachment", () => {
afterEach(() => {
authTesting.resetGoogleChatAuthForTests();
mocks.fetchWithSsrFGuard.mockClear();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("derives a bounded transfer deadline from the payload size", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ attachmentDataRef: { attachmentUploadToken: "token" } }), {
status: 200,
}),
),
);
await uploadGoogleChatAttachment({
account,
space: "spaces/AAA",
filename: "recording.wav",
buffer: Buffer.alloc(1024 * 1024),
});
expect(lastGuardedFetchOptions().timeoutMs).toBeGreaterThan(34_000);
});
it("cancels a stalled upload response body", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createStalledResponse()));
const result = expect(
uploadGoogleChatAttachment({
account,
space: "spaces/AAA",
filename: "recording.wav",
buffer: Buffer.alloc(1024),
}),
).rejects.toThrow("Google Chat upload failed: response body stalled after 30000ms");
await vi.advanceTimersByTimeAsync(30_001);
await result;
});
});
describe("sendGoogleChatMessage", () => {
@ -350,6 +443,7 @@ describe("sendGoogleChatMessage", () => {
messageName: "spaces/AAA/messages/123",
threadName: "spaces/AAA/threads/xyz",
});
expect(lastGuardedFetchOptions().timeoutMs).toBe(30_000);
});
it("does not set messageReplyOption for non-thread sends", async () => {

View file

@ -123,6 +123,37 @@ describe("resolveTeamGroupId", () => {
const result = await resolveTeamGroupId("tok", "team-fallback");
expect(result).toBe("team-fallback");
});
it("caps cache at 500 entries — evicts oldest on overflow", async () => {
vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid" } as never);
const token = "test-token";
for (let i = 0; i < 500; i++) {
await resolveTeamGroupId(token, `team-${i}`);
}
expect(_teamGroupIdCacheForTest.size).toBe(500);
expect(_teamGroupIdCacheForTest.has("team-0")).toBe(true);
expect(_teamGroupIdCacheForTest.has("team-499")).toBe(true);
vi.mocked(fetchGraphJson).mockClear();
await resolveTeamGroupId(token, "team-500");
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
expect(_teamGroupIdCacheForTest.size).toBe(500);
expect(_teamGroupIdCacheForTest.has("team-0")).toBe(false);
expect(_teamGroupIdCacheForTest.has("team-500")).toBe(true);
vi.mocked(fetchGraphJson).mockClear();
await resolveTeamGroupId(token, "team-0");
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
expect(_teamGroupIdCacheForTest.size).toBe(500);
expect(_teamGroupIdCacheForTest.has("team-1")).toBe(false);
expect(_teamGroupIdCacheForTest.has("team-500")).toBe(true);
// team-500 remains cached after team-0 is reinserted at the insertion-order tail.
vi.mocked(fetchGraphJson).mockClear();
await resolveTeamGroupId(token, "team-500");
expect(fetchGraphJson).toHaveBeenCalledTimes(0);
});
});
describe("fetchChannelMessage", () => {

View file

@ -1,4 +1,5 @@
// Msteams plugin module implements graph thread behavior.
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
@ -15,9 +16,11 @@ export type GraphThreadMessage = {
createdDateTime?: string;
};
// TTL cache for team ID -> group GUID mapping.
// Successful lookups use a 10-minute TTL and a 500-entry insertion-order cap.
// Pruning after insert evicts the oldest team IDs before this process cache grows unbounded.
const teamGroupIdCache = new Map<string, { groupId: string; expiresAt: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const TEAM_GROUP_ID_CACHE_MAX_ENTRIES = 500;
function resolveTeamGroupIdCacheExpiresAt(nowRaw = Date.now()): number | undefined {
const now = asDateTimestampMs(nowRaw);
@ -84,6 +87,7 @@ export async function resolveTeamGroupId(
groupId,
expiresAt,
});
pruneMapToMaxSize(teamGroupIdCache, TEAM_GROUP_ID_CACHE_MAX_ENTRIES);
}
return groupId;

View file

@ -1,6 +1,9 @@
// Qa Matrix tests cover scenario runtime shared plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveMatrixQaNoReplyWindowMs } from "./scenario-runtime-shared.js";
import {
buildMatrixReplyArtifact,
resolveMatrixQaNoReplyWindowMs,
} from "./scenario-runtime-shared.js";
describe("matrix scenario runtime shared", () => {
afterEach(() => {
@ -19,4 +22,18 @@ describe("matrix scenario runtime shared", () => {
expect(resolveMatrixQaNoReplyWindowMs(30_000)).toBe(8_000);
}
});
it("keeps reply previews UTF-16 safe without changing empty-body artifacts", () => {
const event = {
kind: "message" as const,
roomId: "!room:matrix-qa.test",
eventId: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
};
const prefix = "a".repeat(199);
expect(buildMatrixReplyArtifact({ ...event, body: `${prefix}😀tail` }).bodyPreview).toBe(prefix);
expect(buildMatrixReplyArtifact({ ...event, body: " " }).bodyPreview).toBe("");
});
});

View file

@ -1,5 +1,6 @@
// Qa Matrix plugin module implements scenario runtime shared behavior.
import { randomUUID } from "node:crypto";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { createMatrixQaClient, type MatrixQaRoomObserver } from "../../substrate/client.js";
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import type { MatrixQaFaultProxyObserver } from "../../substrate/fault-proxy.js";
@ -189,7 +190,7 @@ export function buildMatrixReplyArtifact(
): MatrixQaReplyArtifact {
const replyBody = event.body?.trim();
return {
bodyPreview: replyBody?.slice(0, 200),
bodyPreview: replyBody === undefined ? undefined : truncateUtf16Safe(replyBody, 200),
eventId: event.eventId,
mentions: event.mentions,
relatesTo: event.relatesTo,

View file

@ -89,8 +89,7 @@ describe("engine/gateway/inbound-attachments", () => {
"/tmp/openclaw-qqbot-downloads/a.png",
"/tmp/openclaw-qqbot-downloads/b.png",
]);
// The raw content_type passes through unchanged.
expect(result.imageMediaTypes).toEqual(["image/png", "Image/PNG"]);
expect(result.imageMediaTypes).toEqual(["image/png", "image/png"]);
expect(result.attachmentInfo).toBe("");
});
@ -103,7 +102,7 @@ describe("engine/gateway/inbound-attachments", () => {
);
expect(result.imageUrls).toEqual(["https://cdn.example.test/a.png"]);
expect(result.imageMediaTypes).toEqual(["Image/PNG"]);
expect(result.imageMediaTypes).toEqual(["image/png"]);
expect(result.attachmentLocalPaths).toEqual([null]);
});

View file

@ -1,12 +1,11 @@
// Qqbot plugin module implements inbound attachments behavior.
import { normalizeMimeType } from "openclaw/plugin-sdk/media-mime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { AudioConvertPort } from "../adapter/audio.port.js";
import { downloadFile } from "../utils/file-utils.js";
import { getQQBotMediaDir } from "../utils/platform.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../utils/string-normalize.js";
import { normalizeOptionalString } from "../utils/string-normalize.js";
import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js";
// Re-export the port type for convenience.
@ -118,10 +117,9 @@ export async function processAttachments(
const processTasks = downloadResults.map(
async ({ att, attUrl, isVoice, localPath, audioPath }) => {
const asrReferText = normalizeOptionalString(att.asr_refer_text) ?? "";
// MIME types are case-insensitive (RFC 2045) and relays may emit
// mixed-case values; compare lowercased but keep the raw content_type
// in returned fields.
const normalizedContentType = normalizeLowercaseStringOrEmpty(att.content_type);
// Canonicalize the type/subtype before both classification and propagation.
// Downstream image resolvers intentionally consume canonical MIME values.
const normalizedContentType = normalizeMimeType(att.content_type) ?? "";
const wavUrl =
isVoice && att.voice_wav_url
? att.voice_wav_url.startsWith("//")
@ -138,7 +136,7 @@ export async function processAttachments(
if (localPath) {
if (normalizedContentType.startsWith("image/")) {
log?.debug?.(`Downloaded attachment to: ${localPath}`);
return { localPath, type: "image" as const, contentType: att.content_type, meta };
return { localPath, type: "image" as const, contentType: normalizedContentType, meta };
}
if (isVoice) {
log?.debug?.(`Downloaded attachment to: ${localPath}`);
@ -162,7 +160,7 @@ export async function processAttachments(
localPath: null,
type: "image-fallback" as const,
attUrl,
contentType: att.content_type,
contentType: normalizedContentType,
meta,
};
}

View file

@ -9,8 +9,13 @@ type DeepPartial<T> = { [P in keyof T]?: DeepPartial<T[P]> };
type OpenAICompatibleDelta = DeepPartial<ChatCompletionChunk["choices"][number]["delta"]> & {
reasoning_content?: string;
};
type OpenAICompatibleChoice = Omit<DeepPartial<ChatCompletionChunk["choices"][number]>, "delta"> & {
type OpenAICompatibleChoice = Omit<
DeepPartial<ChatCompletionChunk["choices"][number]>,
"delta" | "message"
> & {
delta?: OpenAICompatibleDelta;
// Some OpenAI-compatible endpoints deliver a full message instead of delta.
message?: OpenAICompatibleDelta;
};
type OpenAICompatibleChatCompletionChunk = Omit<
DeepPartial<ChatCompletionChunk>,
@ -124,6 +129,32 @@ function makeTextChunk(text: string): OpenAICompatibleChatCompletionChunk {
};
}
function makeRefusalChunk(refusal: string): OpenAICompatibleChatCompletionChunk {
return {
id: "chatcmpl-test",
choices: [
{
index: 0,
delta: { role: "assistant", content: null, refusal },
finish_reason: "stop",
},
],
};
}
function makeRefusalMessageChunk(refusal: string): OpenAICompatibleChatCompletionChunk {
return {
id: "chatcmpl-test",
choices: [
{
index: 0,
message: { role: "assistant", content: null, refusal },
finish_reason: "stop",
},
],
};
}
function makeToolCallChunk(
id: string,
name: string,
@ -221,6 +252,30 @@ describe("OpenAI-compatible completions params", () => {
}
});
it("surfaces chat-completions refusal deltas as visible assistant text", async () => {
mockChunksRef.chunks = [makeRefusalChunk("I can't help with that.")];
const result = await streamOpenAICompletions(model, context, {
apiKey: "sk-test",
}).result();
expect(result.content).toStrictEqual([{ type: "text", text: "I can't help with that." }]);
expect(result.stopReason).toBe("stop");
});
it("surfaces aggregated chat-completions message.refusal as visible assistant text", async () => {
mockChunksRef.chunks = [makeRefusalMessageChunk("Requests like this are not allowed.")];
const result = await streamOpenAICompletions(model, context, {
apiKey: "sk-test",
}).result();
expect(result.content).toStrictEqual([
{ type: "text", text: "Requests like this are not allowed." },
]);
expect(result.stopReason).toBe("stop");
});
it("preserves a valid provider-reported usage cost", async () => {
mockChunksRef.chunks = [
makeTextChunk("ok"),

View file

@ -424,13 +424,19 @@ export const streamOpenAICompletions: StreamFunction<
hasFinishReason = true;
}
if (choice.delta) {
// Some OpenAI-compatible endpoints deliver a full `message` instead of
// `delta` (including refusal-only turns with content: null). Normalize
// the same way the managed agent transport does.
const choiceDelta =
choice.delta ??
(choice as { message?: ChatCompletionChunk["choices"][number]["delta"] }).message;
if (choiceDelta) {
// Some endpoints return reasoning in reasoning_content (llama.cpp),
// or reasoning (other openai compatible endpoints)
// Use the first non-empty reasoning field to avoid duplication
// (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
const deltaFields = choice.delta as Record<string, unknown>;
const deltaFields = choiceDelta as Record<string, unknown>;
const shouldEmitReasoning = Boolean(model.reasoning && options?.reasoningEffort);
let foundReasoningField: string | null = null;
for (const field of reasoningFields) {
@ -454,19 +460,27 @@ export const streamOpenAICompletions: StreamFunction<
}
}
if (
choice.delta.content !== null &&
choice.delta.content !== undefined &&
choice.delta.content.length > 0
choiceDelta.content !== null &&
choiceDelta.content !== undefined &&
choiceDelta.content.length > 0
) {
appendPartitionedContent(choice.delta.content, Boolean(foundReasoningField));
appendPartitionedContent(choiceDelta.content, Boolean(foundReasoningField));
}
if (choice?.delta?.tool_calls) {
// Chat Completions can put safety/structured-output refusals in a
// top-level `refusal` field with content null. Surface that as
// visible text so the assistant turn is not empty.
const refusalText = typeof choiceDelta.refusal === "string" ? choiceDelta.refusal : "";
if (refusalText.length > 0) {
appendPartitionedContent(refusalText, Boolean(foundReasoningField));
}
if (choiceDelta.tool_calls) {
flushPartitionedContent();
// The tool-call lane is also a reasoning boundary; seal the thought
// before toolcall_start so thinking_end never trails the action.
sealNativeReasoningBeforeText();
for (const toolCall of choice.delta.tool_calls) {
for (const toolCall of choiceDelta.tool_calls) {
const block = ensureToolCallBlock(toolCall);
if (!block.id && toolCall.id) {
block.id = toolCall.id;
@ -492,7 +506,7 @@ export const streamOpenAICompletions: StreamFunction<
}
}
const reasoningDetails = (choice.delta as { reasoning_details?: unknown })
const reasoningDetails = (choiceDelta as { reasoning_details?: unknown })
.reasoning_details;
if (reasoningDetails && Array.isArray(reasoningDetails)) {
for (const detail of reasoningDetails) {

View file

@ -2785,6 +2785,101 @@ describe("openai transport stream", () => {
expect(output.stopReason).toBe("stop");
});
it("surfaces chat-completions refusal deltas as visible assistant text", async () => {
const model = {
id: "gpt-5.5",
name: "GPT-5.5",
api: "openai-completions" as const,
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
} satisfies Model<"openai-completions">;
const output = createAssistantOutput(model);
const events: CapturedStreamEvent[] = [];
await testing.processOpenAICompletionsStream(
streamChunks([
{
id: "chatcmpl-refusal-delta",
object: "chat.completion.chunk",
created: 1,
model: model.id,
choices: [
{
index: 0,
delta: { role: "assistant", content: null, refusal: "I can't help with that." },
logprobs: null,
finish_reason: "stop",
},
],
},
]),
output,
model,
{ push: (event) => events.push(event as CapturedStreamEvent) },
);
expect(output.content).toStrictEqual([{ type: "text", text: "I can't help with that." }]);
expect(output.stopReason).toBe("stop");
expect(
events.some(
(event) => event.type === "text_delta" && event.delta === "I can't help with that.",
),
).toBe(true);
});
it("surfaces aggregated chat-completions message.refusal as visible assistant text", async () => {
const model = {
id: "gpt-5.5",
name: "GPT-5.5",
api: "openai-completions" as const,
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
} satisfies Model<"openai-completions">;
const output = createAssistantOutput(model);
await testing.processOpenAICompletionsStream(
streamChunks([
{
id: "chatcmpl-refusal-message",
object: "chat.completion.chunk",
created: 1,
model: model.id,
choices: [
{
index: 0,
// Some OpenAI-compatible endpoints deliver a full message instead of delta.
message: {
role: "assistant",
content: null,
refusal: "Requests like this are not allowed.",
},
logprobs: null,
finish_reason: "stop",
} as unknown as ChatCompletionChunk["choices"][number],
],
},
]),
output,
model,
{ push() {} },
);
expect(output.content).toStrictEqual([
{ type: "text", text: "Requests like this are not allowed." },
]);
expect(output.stopReason).toBe("stop");
});
it("filters DeepSeek DSML content without disturbing native tool calls", async () => {
const model = createDeepSeekCompletionsModel();
const output = createAssistantOutput(model);

View file

@ -3166,6 +3166,18 @@ async function processOpenAICompletionsStream(
}
}
}
// Chat Completions can put safety/structured-output refusals in a top-level
// `refusal` field with content null. Surface that as visible text so the
// assistant turn is not empty (Responses path already routes refusal deltas).
const refusalText = typeof choiceDelta.refusal === "string" ? choiceDelta.refusal : "";
if (refusalText) {
const routedDeltas = hasMirroredReasoning
? reasoningTagTextPartitioner.push(refusalText)
: reasoningTagTextPartitioner.pushVisible(refusalText);
for (const routedDelta of routedDeltas) {
appendPartitionedVisibleDelta(routedDelta);
}
}
for (const reasoningDelta of reasoningDeltas) {
if (reasoningDelta.kind === "thinking" && !emitReasoning) {
continue;

View file

@ -6,6 +6,7 @@ type StateMigrationResult = {
skipped: boolean;
changes: string[];
warnings: string[];
notices?: string[];
};
type StartupConvergenceWarning = {
@ -294,6 +295,30 @@ describe("runDoctorConfigPreflight state migration", () => {
expect(startupMigrationLeaseRelease).toHaveBeenCalledOnce();
});
it("records the startup migration checkpoint when state migrations only leave notices", async () => {
needsStartupMigrationCheckpoint.mockReturnValue(true);
autoMigrateLegacyStateDir.mockResolvedValueOnce({
migrated: true,
skipped: false,
changes: [],
warnings: [],
notices: ["Left reviewed residue in place."],
});
await runDoctorConfigPreflight({
migrateLegacyConfig: false,
invalidConfigNote: false,
requireStartupMigrationCheckpoint: true,
});
expect(recordSuccessfulStartupMigrations).toHaveBeenCalledWith({
env: process.env,
lease: startupMigrationLease,
});
expect(note).toHaveBeenCalledWith("- Left reviewed residue in place.", "Doctor notices");
expect(startupMigrationLeaseRelease).toHaveBeenCalledOnce();
});
it("does not acquire the startup migration lease when the checkpoint is current", async () => {
await runDoctorConfigPreflight({
migrateLegacyConfig: false,

View file

@ -100,10 +100,18 @@ export function shouldSkipPluginValidationForDoctorConfigPreflight(
return isTruthyEnvValue(env.OPENCLAW_UPDATE_IN_PROGRESS);
}
function noteStateMigrationResult(result: { changes: string[]; warnings: string[] }): void {
function noteStateMigrationResult(result: {
changes: string[];
warnings: string[];
notices?: string[];
}): void {
if (result.changes.length > 0) {
note(result.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
}
const notices = result.notices ?? [];
if (notices.length > 0) {
note(notices.map((entry) => `- ${entry}`).join("\n"), "Doctor notices");
}
if (result.warnings.length > 0) {
note(result.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
@ -178,7 +186,11 @@ export async function runDoctorConfigPreflight(
let startupMigrationHeartbeat: ReturnType<typeof setInterval> | undefined;
let startupMigrationHeartbeatError: unknown;
const startupMigrationWarnings: string[] = [];
const noteStartupStateMigrationResult = (result: { changes: string[]; warnings: string[] }) => {
const noteStartupStateMigrationResult = (result: {
changes: string[];
warnings: string[];
notices?: string[];
}) => {
startupMigrationWarnings.push(...result.warnings);
noteStateMigrationResult(result);
};

View file

@ -1,7 +1,7 @@
// Doctor security tests cover security audit checks, config findings, and repair output.
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withTempDir } from "../test-helpers/temp-dir.js";
@ -21,6 +21,16 @@ vi.mock("../channels/read-only-account-inspect.js", () => ({
inspectReadOnlyChannelAccount: vi.fn(async () => null),
}));
// These doctor assertions cover core secret fields. Registry integration tests
// own plugin-derived targets, so avoid compiling every bundled plugin here.
vi.mock("../secrets/target-registry-data.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../secrets/target-registry-data.js")>();
return {
...actual,
getSecretTargetRegistry: actual.getCoreSecretTargetRegistry,
};
});
import { noteSecurityWarnings } from "./doctor-security.js";
describe("noteSecurityWarnings gateway exposure", () => {
@ -29,11 +39,6 @@ describe("noteSecurityWarnings gateway exposure", () => {
let prevHome: string | undefined;
let prevServiceKind: string | undefined;
beforeAll(async () => {
listReadOnlyChannelPluginsForConfigMock.mockReturnValue([]);
await noteSecurityWarnings({ gateway: { bind: "loopback" } } as OpenClawConfig);
});
beforeEach(() => {
note.mockClear();
listReadOnlyChannelPluginsForConfigMock.mockReset();

View file

@ -1453,6 +1453,31 @@ describe("doctor health contributions", () => {
});
});
it("prints legacy state migration notices during manual doctor runs", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
const detected = { preview: ["legacy sessions"], warnings: [] };
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
mocks.runLegacyStateMigrations.mockResolvedValue({
changes: [],
warnings: [],
notices: ["Left reviewed legacy residue in place."],
});
const ctx = {
cfg: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { nonInteractive: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(mocks.note).toHaveBeenCalledWith(
"Left reviewed legacy residue in place.",
"Doctor notices",
);
});
it("skips Gateway health probes for exec SecretRefs unless allow-exec is set", async () => {
const contribution = requireDoctorContribution("doctor:gateway-health");
mocks.gatewaySecretInputPathCanWin.mockImplementation(

View file

@ -544,6 +544,10 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
if (migrated.changes.length > 0) {
note(migrated.changes.join("\n"), "Doctor changes");
}
const notices = migrated.notices ?? [];
if (notices.length > 0) {
note(notices.join("\n"), "Doctor notices");
}
if (migrated.warnings.length > 0) {
note(migrated.warnings.join("\n"), "Doctor warnings");
}

View file

@ -1,8 +1,15 @@
/**
* Tests for gateway secret resolution and redacted secret method responses.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
import { isKnownSecretTargetId } from "../../secrets/target-registry.js";
import { describe, expect, it, vi } from "vitest";
// Handler tests only need the registry verdicts they exercise. Dedicated
// target-registry tests own bundled plugin discovery and compilation.
vi.mock("../../secrets/target-registry.js", () => ({
isKnownCoreSecretTargetId: (value: unknown) => value === "talk.providers.*.apiKey",
isKnownSecretTargetId: () => false,
}));
import {
TALK_TEST_PROVIDER_API_KEY_PATH,
TALK_TEST_PROVIDER_API_KEY_PATH_SEGMENTS,
@ -94,12 +101,6 @@ async function expectMemoryStatusResolveUnavailable(params: {
}
describe("secrets handlers", () => {
beforeAll(() => {
// Plugin target metadata is process-stable. Load it as suite setup so the
// unknown-id assertion measures handler validation, not manifest discovery.
isKnownSecretTargetId("unknown.target");
});
function createHandlers(overrides?: {
reloadSecrets?: () => Promise<{ warningCount: number }>;
resolveSecrets?: (params: {

View file

@ -872,7 +872,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
stderr: "",
};
}
return { error: null, status: 0, stdout: "", stderr: "" };
return createLsofResult({ status: 1 });
});
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
@ -912,7 +912,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
};
}
events.push("port-free");
return { error: null, status: 0, stdout: "", stderr: "" };
return createLsofResult({ status: 1 });
});
vi.spyOn(process, "kill").mockReturnValue(true);

View file

@ -320,18 +320,27 @@ function archiveLegacyDebugProxySqlite(params: {
if (existingSources.length === 0) {
return;
}
const existingArchives = existingSources
.map((sourcePath) => `${sourcePath}.migrated`)
.filter(fileExists);
if (existingArchives.length > 0) {
params.warnings.push(
`Left migrated debug proxy capture sidecar in place because archive already exists: ${existingArchives[0]}`,
);
return;
}
const resolutions: Array<{ sourcePath: string; targetPath: string; removed: boolean }> = [];
for (const sourcePath of existingSources) {
const archivedPath = `${sourcePath}.migrated`;
try {
fs.renameSync(sourcePath, `${sourcePath}.migrated`);
if (fileExists(archivedPath)) {
if (fs.readFileSync(sourcePath).equals(fs.readFileSync(archivedPath))) {
fs.rmSync(sourcePath, { force: true });
resolutions.push({ sourcePath, targetPath: archivedPath, removed: true });
continue;
}
let index = 2;
while (fs.existsSync(`${sourcePath}.migrated.${index}`)) {
index++;
}
const nextArchivePath = `${sourcePath}.migrated.${index}`;
fs.renameSync(sourcePath, nextArchivePath);
resolutions.push({ sourcePath, targetPath: nextArchivePath, removed: false });
continue;
}
fs.renameSync(sourcePath, archivedPath);
resolutions.push({ sourcePath, targetPath: archivedPath, removed: false });
} catch (err) {
params.warnings.push(
`Failed archiving debug proxy capture sidecar ${sourcePath}: ${String(err)}`,
@ -339,9 +348,24 @@ function archiveLegacyDebugProxySqlite(params: {
return;
}
}
params.changes.push(
`Archived debug proxy capture sidecar legacy source → ${params.sourcePath}.migrated`,
);
if (
resolutions.every(
(resolution) =>
!resolution.removed && resolution.targetPath === `${resolution.sourcePath}.migrated`,
)
) {
params.changes.push(
`Archived debug proxy capture sidecar legacy source → ${params.sourcePath}.migrated`,
);
return;
}
for (const resolution of resolutions) {
params.changes.push(
resolution.removed
? `Removed already-archived debug proxy capture sidecar legacy source ${resolution.sourcePath}`
: `Archived debug proxy capture sidecar legacy source → ${resolution.targetPath}`,
);
}
}
function archiveLegacyDebugProxyBlobs(params: {
@ -353,15 +377,17 @@ function archiveLegacyDebugProxyBlobs(params: {
return;
}
const archivePath = `${params.blobDir}.migrated`;
if (dirExists(archivePath)) {
params.warnings.push(
`Left migrated debug proxy capture blobs in place because archive already exists: ${archivePath}`,
);
return;
}
try {
fs.renameSync(params.blobDir, archivePath);
params.changes.push(`Archived debug proxy capture blobs → ${archivePath}`);
let targetPath = archivePath;
if (dirExists(archivePath)) {
let index = 2;
while (fs.existsSync(`${params.blobDir}.migrated.${index}`)) {
index++;
}
targetPath = `${params.blobDir}.migrated.${index}`;
}
fs.renameSync(params.blobDir, targetPath);
params.changes.push(`Archived debug proxy capture blobs → ${targetPath}`);
} catch (err) {
params.warnings.push(
`Failed archiving debug proxy capture blobs ${params.blobDir}: ${String(err)}`,

View file

@ -105,6 +105,92 @@ describe("legacy state dir auto-migration", () => {
});
});
it("removes legacy plugin install index source when the existing archive has identical bytes", async () => {
await withStateDirFixture(async (root) => {
const stateDir = path.join(root, "custom-state");
const sourcePath = path.join(stateDir, "plugins", "installs.json");
const archivePath = `${sourcePath}.migrated`;
const legacyJson = JSON.stringify({
records: {
demo: {
source: "npm",
spec: "demo@1.0.0",
},
},
});
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
fs.writeFileSync(sourcePath, legacyJson, "utf8");
fs.writeFileSync(archivePath, legacyJson, "utf8");
const first = await autoMigrateLegacyStateDir({
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(first.warnings).toStrictEqual([]);
expect(first.changes).toContain(
`Removed already-archived plugin install index legacy source ${sourcePath}`,
);
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.readFileSync(archivePath, "utf8")).toBe(legacyJson);
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
installRecords: { demo: { source: "npm", spec: "demo@1.0.0" } },
});
resetAutoMigrateLegacyStateDirForTest();
const second = await autoMigrateLegacyStateDir({
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(second.changes).toStrictEqual([]);
expect(second.warnings).toStrictEqual([]);
});
});
it("renames legacy plugin install index source to the next archive when existing archive differs", async () => {
await withStateDirFixture(async (root) => {
const stateDir = path.join(root, "custom-state");
const sourcePath = path.join(stateDir, "plugins", "installs.json");
const archivePath = `${sourcePath}.migrated`;
const nextArchivePath = `${sourcePath}.migrated.2`;
const legacyJson = JSON.stringify({
records: {
demo: {
source: "npm",
spec: "demo@1.0.0",
},
},
});
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
fs.writeFileSync(sourcePath, legacyJson, "utf8");
fs.writeFileSync(archivePath, "older archive", "utf8");
const first = await autoMigrateLegacyStateDir({
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(first.warnings).toStrictEqual([]);
expect(first.changes).toContain(
`Archived plugin install index legacy source → ${nextArchivePath}`,
);
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.readFileSync(archivePath, "utf8")).toBe("older archive");
expect(fs.readFileSync(nextArchivePath, "utf8")).toBe(legacyJson);
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
installRecords: { demo: { source: "npm", spec: "demo@1.0.0" } },
});
resetAutoMigrateLegacyStateDirForTest();
const second = await autoMigrateLegacyStateDir({
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(second.changes).toStrictEqual([]);
expect(second.warnings).toStrictEqual([]);
});
});
it("only runs once per process until reset", async () => {
await withStateDirFixture(async (root) => {
const legacyDir = path.join(root, ".clawdbot");

View file

@ -242,6 +242,12 @@ type DetectedPluginDoctorStateMigrationPlan = {
preview: string[];
};
type MigrationMessages = {
changes: string[];
warnings: string[];
notices?: string[];
};
// Move the canonical database first so a partial archive never leaves a
// readable database separated from committed WAL rows. Pending sidecars are
// detected and archived without reopening the migrated database.
@ -400,6 +406,61 @@ function hasPendingSqliteSidecarArchive(sourcePath: string, suffixes: readonly s
);
}
type LegacyArchiveResolution = {
sourcePath: string;
targetPath: string;
action: "archived" | "removed";
};
function firstFreeArchivePath(sourcePath: string): string {
for (let index = 2; ; index++) {
const candidate = `${sourcePath}.migrated.${index}`;
if (!fs.existsSync(candidate)) {
return candidate;
}
}
}
function archiveLegacyFileSource(params: {
sourcePath: string;
label: string;
warnings: string[];
}): LegacyArchiveResolution | null {
const archivedPath = `${params.sourcePath}.migrated`;
try {
if (fileExists(archivedPath)) {
// Import has already committed before archival. Identical archive bytes
// preserve the same snapshot, so the leftover source can be removed.
if (fs.readFileSync(params.sourcePath).equals(fs.readFileSync(archivedPath))) {
fs.rmSync(params.sourcePath, { force: true });
return { sourcePath: params.sourcePath, targetPath: archivedPath, action: "removed" };
}
const nextArchivePath = firstFreeArchivePath(params.sourcePath);
fs.renameSync(params.sourcePath, nextArchivePath);
return { sourcePath: params.sourcePath, targetPath: nextArchivePath, action: "archived" };
}
fs.renameSync(params.sourcePath, archivedPath);
return { sourcePath: params.sourcePath, targetPath: archivedPath, action: "archived" };
} catch (err) {
params.warnings.push(`Failed archiving ${params.label} ${params.sourcePath}: ${String(err)}`);
return null;
}
}
function recordArchiveCollisionResolutions(
changes: string[],
label: string,
resolutions: readonly LegacyArchiveResolution[],
): void {
for (const resolution of resolutions) {
changes.push(
resolution.action === "removed"
? `Removed already-archived ${label} legacy source ${resolution.sourcePath}`
: `Archived ${label} legacy source → ${resolution.targetPath}`,
);
}
}
function archiveLegacyPluginStateSidecar(params: {
sourcePath: string;
changes: string[];
@ -411,28 +472,32 @@ function archiveLegacyPluginStateSidecar(params: {
if (existingSources.length === 0) {
return;
}
const existingArchives = existingSources
.map((sourcePath) => `${sourcePath}.migrated`)
.filter(fileExists);
if (existingArchives.length > 0) {
params.warnings.push(
`Left migrated plugin-state sidecar in place because archive already exists: ${existingArchives[0]}`,
);
return;
}
const resolutions: LegacyArchiveResolution[] = [];
for (const sourcePath of existingSources) {
const archivedPath = `${sourcePath}.migrated`;
try {
fs.renameSync(sourcePath, archivedPath);
} catch (err) {
params.warnings.push(`Failed archiving plugin-state sidecar ${sourcePath}: ${String(err)}`);
const resolution = archiveLegacyFileSource({
sourcePath,
label: "plugin-state sidecar",
warnings: params.warnings,
});
if (!resolution) {
return;
}
resolutions.push(resolution);
}
if (
resolutions.every(
(resolution) =>
resolution.action === "archived" &&
resolution.targetPath === `${resolution.sourcePath}.migrated`,
)
) {
params.changes.push(
`Archived plugin-state sidecar legacy source → ${params.sourcePath}.migrated`,
);
} else {
recordArchiveCollisionResolutions(params.changes, "plugin-state sidecar", resolutions);
}
params.changes.push(
`Archived plugin-state sidecar legacy source → ${params.sourcePath}.migrated`,
);
}
function readLegacyInstalledPluginIndex(sourcePath: string): InstalledPluginIndex | null {
@ -639,21 +704,19 @@ function archiveLegacyInstalledPluginIndex(params: {
changes: string[];
warnings: string[];
}): void {
const archivedPath = `${params.sourcePath}.migrated`;
if (fileExists(archivedPath)) {
params.warnings.push(
`Left migrated plugin install index in place because archive already exists: ${archivedPath}`,
);
const resolution = archiveLegacyFileSource({
sourcePath: params.sourcePath,
label: "plugin install index",
warnings: params.warnings,
});
if (!resolution) {
return;
}
try {
fs.renameSync(params.sourcePath, archivedPath);
params.changes.push(`Archived plugin install index legacy source → ${archivedPath}`);
} catch (err) {
params.warnings.push(
`Failed archiving plugin install index ${params.sourcePath}: ${String(err)}`,
);
}
params.changes.push(
resolution.action === "removed"
? `Removed already-archived plugin install index legacy source ${params.sourcePath}`
: `Archived plugin install index legacy source → ${resolution.targetPath}`,
);
}
function archiveLegacyTaskStateSidecar(params: {
@ -668,28 +731,31 @@ function archiveLegacyTaskStateSidecar(params: {
if (existingSources.length === 0) {
return;
}
const existingArchives = existingSources
.map((sourcePath) => `${sourcePath}.migrated`)
.filter(fileExists);
if (existingArchives.length > 0) {
params.warnings.push(
`Left migrated ${params.label} sidecar in place because archive already exists: ${existingArchives[0]}`,
);
return;
}
const resolutions: LegacyArchiveResolution[] = [];
for (const sourcePath of existingSources) {
try {
fs.renameSync(sourcePath, `${sourcePath}.migrated`);
} catch (err) {
params.warnings.push(
`Failed archiving ${params.label} sidecar ${sourcePath}: ${String(err)}`,
);
const resolution = archiveLegacyFileSource({
sourcePath,
label: `${params.label} sidecar`,
warnings: params.warnings,
});
if (!resolution) {
return;
}
resolutions.push(resolution);
}
if (
resolutions.every(
(resolution) =>
resolution.action === "archived" &&
resolution.targetPath === `${resolution.sourcePath}.migrated`,
)
) {
params.changes.push(
`Archived ${params.label} sidecar legacy source → ${params.sourcePath}.migrated`,
);
} else {
recordArchiveCollisionResolutions(params.changes, `${params.label} sidecar`, resolutions);
}
params.changes.push(
`Archived ${params.label} sidecar legacy source → ${params.sourcePath}.migrated`,
);
}
function hardenLegacyImportSource(params: {
@ -712,29 +778,31 @@ function archiveLegacyImportSource(params: {
changes: string[];
warnings: string[];
}): void {
const archivedPath = `${params.sourcePath}.migrated`;
if (fileExists(archivedPath)) {
params.warnings.push(
`Left migrated ${params.label} source in place because ${archivedPath} already exists`,
);
return;
}
if (!hardenLegacyImportSource(params)) {
return;
}
try {
fs.renameSync(params.sourcePath, archivedPath);
const resolution = archiveLegacyFileSource({
sourcePath: params.sourcePath,
label: `${params.label} legacy source`,
warnings: params.warnings,
});
if (!resolution) {
return;
}
if (resolution.action === "archived") {
try {
fs.chmodSync(archivedPath, 0o600);
fs.chmodSync(resolution.targetPath, 0o600);
} catch (err) {
params.warnings.push(
`Failed securing archived ${params.label} legacy source: ${String(err)}`,
);
}
params.changes.push(`Archived ${params.label} legacy source → ${archivedPath}`);
} catch (err) {
params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
}
params.changes.push(
resolution.action === "removed"
? `Removed already-archived ${params.label} legacy source ${params.sourcePath}`
: `Archived ${params.label} legacy source → ${resolution.targetPath}`,
);
}
function listSqliteColumns(db: DatabaseSync, table: string): Set<string> {
@ -3728,6 +3796,7 @@ type StateDirMigrationResult = {
skipped: boolean;
changes: string[];
warnings: string[];
notices?: string[];
};
function resolveSymlinkTarget(linkPath: string): string | null {
@ -3980,6 +4049,7 @@ export async function autoMigrateLegacyTaskStateSidecars(params: {
skipped: boolean;
changes: string[];
warnings: string[];
notices?: string[];
}> {
if (autoMigrateTaskStateSidecarsChecked) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
@ -4725,9 +4795,10 @@ async function runPluginDoctorStateMigrationPlans(params: {
detected: LegacyStateDetection;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{ changes: string[]; warnings: string[] }> {
}): Promise<MigrationMessages> {
const changes: string[] = [];
const warnings: string[] = [];
const notices: string[] = [];
const refreshedPlans = await collectPluginDoctorStateMigrationPlans({
cfg: params.config,
env: params.env,
@ -4753,11 +4824,12 @@ async function runPluginDoctorStateMigrationPlans(params: {
});
changes.push(...result.changes);
warnings.push(...result.warnings);
notices.push(...(result.notices ?? []));
} catch (err) {
warnings.push(`Failed migrating ${plan.migration.label}: ${String(err)}`);
}
}
return { changes, warnings };
return notices.length > 0 ? { changes, warnings, notices } : { changes, warnings };
}
export async function autoMigrateLegacyPluginDoctorState(params: {
@ -4770,6 +4842,7 @@ export async function autoMigrateLegacyPluginDoctorState(params: {
skipped: boolean;
changes: string[];
warnings: string[];
notices?: string[];
}> {
const env = params.env ?? process.env;
const stateDirResult = await autoMigrateLegacyStateDir({
@ -4784,12 +4857,14 @@ export async function autoMigrateLegacyPluginDoctorState(params: {
});
const changes = [...stateDirResult.changes, ...stateSchema.changes];
const warnings = [...stateDirResult.warnings, ...stateSchema.warnings];
const notices = [...(stateDirResult.notices ?? [])];
if (stateSchema.warnings.length > 0) {
return {
migrated: stateDirResult.migrated || stateSchema.changes.length > 0,
skipped: false,
changes,
warnings,
...(notices.length > 0 ? { notices } : {}),
};
}
const plans = await collectPluginDoctorStateMigrationPlans({
@ -4810,6 +4885,7 @@ export async function autoMigrateLegacyPluginDoctorState(params: {
});
changes.push(...result.changes);
warnings.push(...result.warnings);
notices.push(...(result.notices ?? []));
} catch (err) {
warnings.push(`Failed migrating ${plan.migration.label}: ${String(err)}`);
}
@ -4819,6 +4895,7 @@ export async function autoMigrateLegacyPluginDoctorState(params: {
skipped: false,
changes,
warnings,
...(notices.length > 0 ? { notices } : {}),
};
}
@ -5026,7 +5103,7 @@ export async function runLegacyStateMigrations(params: {
env?: NodeJS.ProcessEnv;
now?: () => number;
recoverCorruptTargetStore?: boolean;
}): Promise<{ changes: string[]; warnings: string[] }> {
}): Promise<MigrationMessages> {
const now = params.now ?? (() => Date.now());
const detected = params.detected;
const env = params.env ?? process.env;
@ -5135,6 +5212,9 @@ export async function runLegacyStateMigrations(params: {
...agentDir.warnings,
...channelPlans.warnings,
],
...(pluginPlans.notices && pluginPlans.notices.length > 0
? { notices: [...pluginPlans.notices] }
: {}),
};
}
@ -5759,6 +5839,7 @@ export async function autoMigrateLegacyState(params: {
skipped: boolean;
changes: string[];
warnings: string[];
notices?: string[];
}> {
if (autoMigrateChecked) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
@ -5805,7 +5886,7 @@ export async function autoMigrateLegacyState(params: {
pluginSessionStoreAgentIds,
});
const logMigrationResults = (changes: string[], warnings: string[]) => {
const logMigrationResults = (changes: string[], warnings: string[], notices: string[]) => {
const logger = params.log ?? createSubsystemLogger("state-migrations");
if (changes.length > 0) {
logger.info(
@ -5817,6 +5898,11 @@ export async function autoMigrateLegacyState(params: {
`Legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
if (notices.length > 0) {
logger.info(
`Legacy state migration notes:\n${notices.map((entry) => `- ${entry}`).join("\n")}`,
);
}
};
const detected = await detectLegacyStateMigrations({
@ -5913,7 +5999,8 @@ export async function autoMigrateLegacyState(params: {
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
];
logMigrationResults(changes, warnings);
const notices = [...(stateDirResult.notices ?? []), ...(pluginPlans.notices ?? [])];
logMigrationResults(changes, warnings, notices);
return {
migrated:
stateDirResult.migrated ||
@ -5936,6 +6023,7 @@ export async function autoMigrateLegacyState(params: {
skipped: true,
changes,
warnings,
...(notices.length > 0 ? { notices } : {}),
};
}
if (
@ -5969,7 +6057,8 @@ export async function autoMigrateLegacyState(params: {
...orphanKeys.warnings,
...acpSessionMetadata.warnings,
];
logMigrationResults(changes, warnings);
const notices = [...(stateDirResult.notices ?? [])];
logMigrationResults(changes, warnings, notices);
return {
migrated:
stateDirResult.migrated ||
@ -5979,6 +6068,7 @@ export async function autoMigrateLegacyState(params: {
skipped: false,
changes,
warnings,
...(notices.length > 0 ? { notices } : {}),
};
}
@ -6088,13 +6178,15 @@ export async function autoMigrateLegacyState(params: {
...agentDir.warnings,
...channelPlans.warnings,
];
const notices = [...(stateDirResult.notices ?? []), ...(pluginPlans.notices ?? [])];
logMigrationResults(changes, warnings);
logMigrationResults(changes, warnings, notices);
return {
migrated: changes.length > 0,
skipped: false,
changes,
warnings,
...(notices.length > 0 ? { notices } : {}),
};
}

View file

@ -36,6 +36,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
"bonjour",
"browser",
"canvas",
"codex-supervisor",
"device-pair",
"diagnostics-otel",
"diagnostics-prometheus",

View file

@ -84,8 +84,8 @@ export type PluginDoctorStateMigration = {
oauthDir: string;
context: PluginDoctorStateMigrationContext;
}) =>
| Promise<{ changes: string[]; warnings: string[] }>
| { changes: string[]; warnings: string[] };
| Promise<{ changes: string[]; warnings: string[]; notices?: string[] }>
| { changes: string[]; warnings: string[]; notices?: string[] };
};
export type PluginDoctorStateMigrationEntry = {

View file

@ -0,0 +1,71 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { archiveLegacyStateSource } from "./doctor-state-migration-fs.js";
describe("archiveLegacyStateSource", () => {
let dir: string;
beforeEach(async () => {
dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-fs-")));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("archives a source without an existing archive", async () => {
const filePath = path.join(dir, "state.json");
await fs.writeFile(filePath, "{}");
const changes: string[] = [];
const warnings: string[] = [];
await archiveLegacyStateSource({ filePath, label: "test state", changes, warnings });
expect(warnings).toEqual([]);
expect(changes).toEqual([`Archived test state legacy source -> ${filePath}.migrated`]);
await expect(fs.readFile(`${filePath}.migrated`, "utf8")).resolves.toBe("{}");
});
it("removes the source when an identical archive already exists", async () => {
const filePath = path.join(dir, "state.json");
await fs.writeFile(filePath, "{}");
await fs.writeFile(`${filePath}.migrated`, "{}");
const changes: string[] = [];
const warnings: string[] = [];
await archiveLegacyStateSource({ filePath, label: "test state", changes, warnings });
expect(warnings).toEqual([]);
expect(changes).toEqual([`Removed already-archived test state legacy source ${filePath}`]);
await expect(fs.stat(filePath)).rejects.toThrow();
});
it("archives under a free suffix when a differing archive already exists", async () => {
const filePath = path.join(dir, "state.json");
await fs.writeFile(filePath, `{"newer":true}`);
await fs.writeFile(`${filePath}.migrated`, "{}");
const changes: string[] = [];
const warnings: string[] = [];
await archiveLegacyStateSource({ filePath, label: "test state", changes, warnings });
expect(warnings).toEqual([]);
expect(changes).toEqual([`Archived test state legacy source -> ${filePath}.migrated.2`]);
await expect(fs.readFile(`${filePath}.migrated.2`, "utf8")).resolves.toBe(`{"newer":true}`);
await expect(fs.readFile(`${filePath}.migrated`, "utf8")).resolves.toBe("{}");
});
it("keeps a failed archive as a warning", async () => {
const filePath = path.join(dir, "missing.json");
const changes: string[] = [];
const warnings: string[] = [];
await archiveLegacyStateSource({ filePath, label: "test state", changes, warnings });
expect(changes).toEqual([]);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("Failed archiving test state legacy source");
});
});

View file

@ -23,16 +23,39 @@ export async function archiveLegacyStateSource(params: {
warnings: string[];
}): Promise<void> {
const archivedPath = `${params.filePath}.migrated`;
if (await legacyStateFileExists(archivedPath)) {
params.warnings.push(
`Left migrated ${params.label} source in place because ${archivedPath} already exists`,
);
return;
}
try {
if (await legacyStateFileExists(archivedPath)) {
// Import commits before archival, so an existing archive must converge
// instead of re-warning every startup (#102749): identical bytes already
// preserve the snapshot; differing bytes archive under a free suffix.
const [sourceBytes, archiveBytes] = await Promise.all([
fs.readFile(params.filePath),
fs.readFile(archivedPath),
]);
if (sourceBytes.equals(archiveBytes)) {
await fs.rm(params.filePath, { force: true });
params.changes.push(
`Removed already-archived ${params.label} legacy source ${params.filePath}`,
);
return;
}
const nextArchivePath = await firstFreeArchivePath(params.filePath);
await fs.rename(params.filePath, nextArchivePath);
params.changes.push(`Archived ${params.label} legacy source -> ${nextArchivePath}`);
return;
}
await fs.rename(params.filePath, archivedPath);
params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
}
}
async function firstFreeArchivePath(sourcePath: string): Promise<string> {
for (let index = 2; ; index++) {
const candidate = `${sourcePath}.migrated.${index}`;
if (!(await legacyStateFileExists(candidate))) {
return candidate;
}
}
}

View file

@ -50,7 +50,7 @@ describe("native app i18n inventory", () => {
expect(entries.some((entry) => entry.source === "QR Scanner Unavailable")).toBe(true);
expect(entries.some((entry) => entry.source === "Request ID: \\(value)")).toBe(true);
expect(entries.some((entry) => entry.source === "Open ${row.title}")).toBe(true);
expect(entries.some((entry) => entry.source === "$deviceModel · $appVersion")).toBe(true);
expect(entries.some((entry) => entry.source === "Preview · $domain")).toBe(true);
expect(entries.some((entry) => entry.source === "Approval command copied")).toBe(true);
expect(entries.some((entry) => entry.source === "Save Profile")).toBe(true);
expect(entries.some((entry) => entry.source === "Mute")).toBe(true);

View file

@ -1,4 +1,6 @@
// Control UI E2E tests cover browser Talk start and stop through a real page.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
@ -92,6 +94,14 @@ async function installTalkBrowserFixtures(page: Page) {
});
}
async function captureComposerProof(page: Page, fileName: string) {
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "voice-controls");
await mkdir(artifactDir, { recursive: true });
await page
.locator(".agent-chat__composer-shell")
.screenshot({ path: path.join(artifactDir, fileName) });
}
async function installBlockedMicrophoneFixture(page: Page) {
await page.addInitScript(() => {
Object.defineProperty(navigator, "mediaDevices", {
@ -185,6 +195,17 @@ describeControlUiE2e("Control UI browser Talk", () => {
await expect
.poll(() => page.locator('.agent-chat__voice-activity[data-status="connecting"]').count())
.toBe(1);
// The level meter renders inside the stop-voice pill button, not as a
// separate floating row above the composer.
await expect
.poll(() =>
page.locator('button[aria-label="Stop voice input"] .agent-chat__voice-activity').count(),
)
.toBe(1);
// Phone widths keep the pill wide enough for the 7-bar meter instead of
// collapsing it to the generic 44px square control size.
const pillBox = await page.getByRole("button", { name: "Stop voice input" }).boundingBox();
expect(pillBox?.width ?? 0).toBeGreaterThanOrEqual(60);
await expect
.poll(() =>
page
@ -206,7 +227,7 @@ describeControlUiE2e("Control UI browser Talk", () => {
.toBe(1);
await expect.poll(() => page.locator(".agent-chat__talk-status-text").count()).toBe(0);
await expect
.poll(() => page.locator('[role="status"] .agent-chat__sr-only').textContent())
.poll(() => page.locator('[role="status"].agent-chat__voice-status').textContent())
.toBe("Listening...");
const reducedMotionTransform = await page
.locator(".agent-chat__voice-activity-bar")
@ -281,6 +302,126 @@ describeControlUiE2e("Control UI browser Talk", () => {
}
});
it("keeps stop-voice and stop-run controls visually distinct while both are active", async () => {
const browser = await chromium.launch({ executablePath: chromiumExecutablePath });
const context = await browser.newContext({ permissions: ["microphone"] });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["chat.send"],
methodResponses: {
"talk.client.create": {
provider: "google",
transport: "provider-websocket",
protocol: "google-live-bidi",
// Fake harness token, assembled so secret scanners do not flag it.
clientSecret: ["auth_tokens", "browser-talk-e2e"].join("/"),
websocketUrl:
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained",
audio: {
inputEncoding: "pcm16",
inputSampleRateHz: 16_000,
outputEncoding: "pcm16",
outputSampleRateHz: 24_000,
},
},
},
});
await installTalkBrowserFixtures(page);
try {
await page.goto(`${server.baseUrl}chat`);
await page.setViewportSize({ width: 1366, height: 900 });
await page.getByRole("button", { name: "Start voice input" }).click();
await gateway.waitForRequest("talk.client.create");
await gateway.deliverLatest({ setupComplete: {} });
const stopVoice = page.getByRole("button", { name: "Stop voice input" });
await expect.poll(() => stopVoice.isVisible()).toBe(true);
await page.evaluate(() => {
const state = (
window as Window & {
openclawTalkE2eState?: {
inputProcessor?: {
onaudioprocess?: (event: {
inputBuffer: { getChannelData: () => Float32Array };
}) => void;
};
meterLevel?: number;
};
}
).openclawTalkE2eState;
if (state) {
state.meterLevel = 0.25;
}
state?.inputProcessor?.onaudioprocess?.({
inputBuffer: { getChannelData: () => new Float32Array(4096).fill(0.25) },
});
});
await expect
.poll(async () =>
Number(await page.locator(".agent-chat__voice-activity").getAttribute("data-level")),
)
.toBeGreaterThan(0);
await captureComposerProof(page, "01-voice-live-listening.png");
// Enter-sends while voice is active; the deferred chat.send keeps the
// run abortable so both stop controls render side by side.
const textarea = page.locator(".agent-chat__input textarea");
await textarea.fill("Keep working on the report");
await textarea.press("Enter");
const sendRequest = await gateway.waitForRequest("chat.send");
const runId =
typeof sendRequest.params === "object" &&
sendRequest.params !== null &&
"idempotencyKey" in sendRequest.params
? String(sendRequest.params.idempotencyKey)
: "";
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
await gateway.emitGatewayEvent("chat", {
deltaText: "Working on it.",
message: {
content: [{ text: "Working on it.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "delta",
});
const stopRun = page.getByRole("button", { name: "Stop generating" });
await expect.poll(() => stopRun.isVisible()).toBe(true);
await expect.poll(() => stopVoice.isVisible()).toBe(true);
expect(
await stopVoice.evaluate((node) => node.classList.contains("chat-send-btn--voice-live")),
).toBe(true);
expect(
await stopVoice.evaluate((node) => node.classList.contains("chat-send-btn--stop")),
).toBe(false);
expect(await stopVoice.locator(".agent-chat__voice-activity").count()).toBe(1);
expect(await page.locator(".chat-send-btn--stop").count()).toBe(1);
await captureComposerProof(page, "02-voice-plus-run-stop.png");
await page.emulateMedia({ colorScheme: "dark" });
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.themeMode))
.toBe("dark");
await captureComposerProof(page, "03-voice-plus-run-stop-dark.png");
await stopVoice.hover();
await captureComposerProof(page, "04-voice-live-hover-stop-glyph.png");
// Stopping voice must leave the run (and its stop control) untouched.
await stopVoice.click();
await expect.poll(() => stopVoice.count()).toBe(0);
await expect.poll(() => stopRun.isVisible()).toBe(true);
expect(await gateway.getRequests("chat.abort")).toHaveLength(0);
} finally {
await context.close();
await browser.close();
}
});
it("renders streamed relay assistant transcript deltas as readable text", async () => {
const browser = await chromium.launch({ executablePath: chromiumExecutablePath });
const context = await browser.newContext({ permissions: ["microphone"] });

View file

@ -666,6 +666,20 @@
"path": "ui/src/components/github-link-hovercard.ts",
"text": "Open"
},
{
"count": 2,
"kind": "html-text",
"name": "text",
"path": "ui/src/components/lobster-pet.ts",
"text": "z"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/components/lobster-pet.ts",
"text": "Z"
},
{
"count": 1,
"kind": "html-attribute",

View file

@ -98,7 +98,8 @@ describe("chat run controls", () => {
container,
);
const stopVoiceButton = getButton(container, 'button[aria-label="Stop voice input"]');
expect(stopVoiceButton.classList.contains("chat-send-btn--stop")).toBe(true);
expect(stopVoiceButton.classList.contains("chat-send-btn--voice-live")).toBe(true);
expect(stopVoiceButton.querySelector(".agent-chat__voice-activity")).not.toBeNull();
stopVoiceButton.click();
expect(onToggleVoice).toHaveBeenCalledTimes(2);
});
@ -145,6 +146,13 @@ describe("chat run controls", () => {
`button[aria-label="${t("chat.runControls.stopGenerating")}"]`,
);
// Redesign guard: the two controls must stay visually distinct — a live
// waveform pill for voice and a single danger stop square for the run.
expect(stopVoiceButton.classList.contains("chat-send-btn--voice-live")).toBe(true);
expect(stopVoiceButton.classList.contains("chat-send-btn--stop")).toBe(false);
expect(stopGenerationButton.classList.contains("chat-send-btn--stop")).toBe(true);
expect(container.querySelectorAll(".chat-send-btn--stop")).toHaveLength(1);
stopVoiceButton.click();
stopGenerationButton.click();
expect(onToggleVoice).toHaveBeenCalledTimes(1);

View file

@ -1784,19 +1784,41 @@ describe("chat voice controls", () => {
realtimeTalkInputLevel: inputLevel,
});
const statusRegion = container.querySelector('[role="status"].agent-chat__talk-status');
const visualizer = statusRegion?.querySelector<HTMLElement>(
const stopVoiceButton = container.querySelector('button[aria-label="Stop voice input"]');
const visualizer = stopVoiceButton?.querySelector<HTMLElement>(
`.agent-chat__voice-activity[data-status="${status}"]`,
);
expect(visualizer?.getAttribute("data-level")).toBe("0.64");
expect(visualizer?.getAttribute("data-source")).toBe("microphone");
expect(visualizer?.getAttribute("role")).toBe("img");
expect(visualizer?.getAttribute("aria-label")).toBe(t("chat.composer.microphoneInput"));
expect(visualizer?.getAttribute("aria-hidden")).toBe("true");
expect(visualizer?.querySelectorAll(".agent-chat__voice-activity-bar")).toHaveLength(7);
const statusRegion = container.querySelector('[role="status"].agent-chat__voice-status');
expect(statusRegion?.getAttribute("aria-live")).toBe("polite");
expect(statusRegion?.getAttribute("aria-atomic")).toBe("true");
expect(statusRegion?.querySelector(".agent-chat__sr-only")?.textContent?.trim()).toBe(label);
expect(statusRegion?.querySelector(".agent-chat__talk-status-text")).toBeNull();
expect(statusRegion?.textContent?.trim()).toBe(label);
expect(container.querySelector(".agent-chat__talk-status")).toBeNull();
});
it("keeps the stop control without a live meter when a running session errors", () => {
const container = renderChatView({
realtimeTalkActive: true,
realtimeTalkStatus: "error",
realtimeTalkDetail: "Microphone unavailable",
});
const stopVoiceButton = requireElement(
container,
'[aria-label="Stop voice input"]',
"stop voice input button",
);
expect(stopVoiceButton.classList.contains("chat-send-btn--voice-error")).toBe(true);
expect(stopVoiceButton.querySelector(".agent-chat__voice-activity")).toBeNull();
expect(container.querySelector(".agent-chat__voice-status")).toBeNull();
expect(
container
.querySelector('[role="alert"].agent-chat__talk-status .agent-chat__talk-status-text')
?.textContent?.trim(),
).toBe("Microphone unavailable");
});
it("clamps the rendered microphone level", () => {

View file

@ -45,7 +45,11 @@ import type { RealtimeTalkLevelSignal } from "../realtime-talk-level.ts";
import type { RealtimeTalkStatus } from "../realtime-talk.ts";
import { CHAT_RUN_STATUS_TOAST_DURATION_MS, type ChatRunUiStatus } from "../run-lifecycle.ts";
import type { CompactionStatus, FallbackStatus } from "../tool-stream.ts";
import { renderChatVoiceActivity } from "./chat-voice-activity.ts";
import {
renderChatVoiceError,
renderMicrophoneActivity,
voiceStatusLabel,
} from "./chat-voice-activity.ts";
const COMPACTION_TOAST_DURATION_MS = 5000;
const FALLBACK_TOAST_DURATION_MS = 8000;
@ -1738,6 +1742,9 @@ export type ChatRunControlsProps = {
isBusy: boolean;
sending: boolean;
voiceActive?: boolean;
voiceStatus?: RealtimeTalkStatus;
voiceDetail?: string | null;
voiceInputLevel?: RealtimeTalkLevelSignal;
onAbort?: () => void;
onExport: () => void;
onNewSession: () => void;
@ -1771,19 +1778,42 @@ function renderChatPrimaryActions(props: ChatRunControlsProps) {
`
: nothing;
// Transports keep the session active while reporting status "error"; the
// alert row above the composer owns the error message, so the control keeps
// only its stop affordance instead of a fake listening meter plus a
// duplicate announcement.
const voiceErrored = props.voiceStatus === "error";
return html`
${props.voiceActive && props.onToggleVoice
? html`
<openclaw-tooltip .content=${t("chat.composer.stopVoiceInput")}>
<button
class="chat-send-btn chat-send-btn--stop"
class="chat-send-btn chat-send-btn--voice-live${voiceErrored
? " chat-send-btn--voice-error"
: ""}"
@click=${props.onToggleVoice}
aria-label=${t("chat.composer.stopVoiceInput")}
>
${icons.stop}
<span class="agent-chat__control-label">${t("chat.composer.stopVoiceInput")}</span>
${voiceErrored
? nothing
: renderMicrophoneActivity({
status: props.voiceStatus,
inputLevel: props.voiceInputLevel,
})}
<span class="chat-send-btn__voice-stop-glyph">${icons.stop}</span>
</button>
</openclaw-tooltip>
${voiceErrored
? nothing
: html`
<span
class="agent-chat__sr-only agent-chat__voice-status"
role="status"
aria-live="polite"
aria-atomic="true"
>${voiceStatusLabel(props.voiceStatus, props.voiceDetail)}</span
>
`}
${abortAction}
`
: props.canAbort
@ -2169,6 +2199,9 @@ export function renderChatComposer(props: ChatComposerProps) {
isBusy,
sending: props.sending,
voiceActive: props.realtimeTalkActive,
voiceStatus: props.realtimeTalkStatus,
voiceDetail: props.realtimeTalkDetail,
voiceInputLevel: props.realtimeTalkInputLevel,
onAbort: props.onAbort,
onExport: () => exportMarkdown(props),
onNewSession: props.onNewSession,
@ -2292,11 +2325,9 @@ export function renderChatComposer(props: ChatComposerProps) {
}}
/>
${renderChatVoiceActivity({
active: props.realtimeTalkActive,
${renderChatVoiceError({
status: props.realtimeTalkStatus,
detail: props.realtimeTalkDetail,
inputLevel: props.realtimeTalkInputLevel,
onDismissError: props.onDismissRealtimeTalkError,
})}

View file

@ -69,21 +69,16 @@ if (!customElements.get(MICROPHONE_ACTIVITY_TAG)) {
customElements.define(MICROPHONE_ACTIVITY_TAG, MicrophoneActivityElement);
}
type ChatVoiceActivityProps = {
active?: boolean;
status?: RealtimeTalkStatus;
detail?: string | null;
inputLevel?: RealtimeTalkLevelSignal;
onDismissError?: () => void;
};
function activeStatus(
status: RealtimeTalkStatus | undefined,
): "connecting" | "listening" | "thinking" {
return status === "connecting" || status === "thinking" ? status : "listening";
}
function statusLabel(status: RealtimeTalkStatus | undefined, detail: string | null | undefined) {
export function voiceStatusLabel(
status: RealtimeTalkStatus | undefined,
detail: string | null | undefined,
) {
const explicitDetail = detail?.trim();
if (explicitDetail) {
return explicitDetail;
@ -97,52 +92,54 @@ function statusLabel(status: RealtimeTalkStatus | undefined, detail: string | nu
return "Listening...";
}
export function renderChatVoiceActivity(
props: ChatVoiceActivityProps,
): TemplateResult | typeof nothing {
if (props.status === "error" && props.detail) {
return html`
<div class="agent-chat__stt-interim agent-chat__talk-status" role="alert">
<span class="agent-chat__talk-status-text">${props.detail}</span>
${props.onDismissError
? html`
<openclaw-tooltip .content=${t("chat.composer.dismissVoiceInputError")}>
<button
class="callout__dismiss"
type="button"
@click=${props.onDismissError}
aria-label=${t("chat.composer.dismissVoiceInputError")}
>
${icons.x}
</button>
</openclaw-tooltip>
`
: nothing}
</div>
`;
}
if (!props.active) {
type MicrophoneActivityProps = {
status?: RealtimeTalkStatus;
inputLevel?: RealtimeTalkLevelSignal;
};
// Class names and data attributes are asserted by the talk e2e suite; the
// element is decorative inside the labeled stop-voice button, so it stays
// aria-hidden while `data-status` keeps driving the bar animations.
export function renderMicrophoneActivity(props: MicrophoneActivityProps): TemplateResult {
return html`
<openclaw-microphone-activity
class="agent-chat__voice-activity"
data-status=${activeStatus(props.status)}
data-source="microphone"
aria-hidden="true"
.signal=${props.inputLevel ?? EMPTY_LEVEL_SIGNAL}
>
</openclaw-microphone-activity>
`;
}
type ChatVoiceErrorProps = {
status?: RealtimeTalkStatus;
detail?: string | null;
onDismissError?: () => void;
};
export function renderChatVoiceError(props: ChatVoiceErrorProps): TemplateResult | typeof nothing {
if (props.status !== "error" || !props.detail) {
return nothing;
}
const status = activeStatus(props.status);
return html`
<div
class="agent-chat__stt-interim agent-chat__talk-status agent-chat__talk-status--active"
role="status"
aria-live="polite"
aria-atomic="true"
>
<openclaw-microphone-activity
class="agent-chat__voice-activity"
data-status=${status}
data-source="microphone"
role="img"
aria-label=${t("chat.composer.microphoneInput")}
.signal=${props.inputLevel ?? EMPTY_LEVEL_SIGNAL}
>
</openclaw-microphone-activity>
<span class="agent-chat__sr-only">${statusLabel(props.status, props.detail)}</span>
<div class="agent-chat__stt-interim agent-chat__talk-status" role="alert">
<span class="agent-chat__talk-status-text">${props.detail}</span>
${props.onDismissError
? html`
<openclaw-tooltip .content=${t("chat.composer.dismissVoiceInputError")}>
<button
class="callout__dismiss"
type="button"
@click=${props.onDismissError}
aria-label=${t("chat.composer.dismissVoiceInputError")}
>
${icons.x}
</button>
</openclaw-tooltip>
`
: nothing}
</div>
`;
}

View file

@ -1521,49 +1521,24 @@ openclaw-chat-page {
color: var(--text);
}
.agent-chat__talk-status--active {
justify-content: center;
min-height: 30px;
padding-top: 8px;
}
.agent-chat__talk-status-text {
min-width: 0;
flex: 1;
}
/* Bars-only meter; the stop-voice pill button supplies the chrome around it. */
.agent-chat__voice-activity {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 64px;
height: 24px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border));
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 7%, var(--card));
box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 12%, transparent);
}
.agent-chat__voice-activity::before {
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(
circle at 50% 50%,
color-mix(in srgb, var(--accent) 12%, transparent),
transparent 72%
);
content: "";
pointer-events: none;
gap: 3px;
height: 20px;
}
.agent-chat__voice-activity-bar {
position: relative;
width: 3px;
height: 16px;
height: 18px;
flex: 0 0 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 88%, var(--text));
@ -1634,6 +1609,16 @@ openclaw-chat-page {
.agent-chat__voice-activity[data-status="thinking"] .agent-chat__voice-activity-bar:nth-child(5) {
transform: scaleY(0.72);
}
.chat-send-btn--voice-live::after {
animation: none !important;
opacity: 0.45;
}
.chat-send-btn--voice-live .agent-chat__voice-activity,
.chat-send-btn__voice-stop-glyph {
transition: none !important;
}
}
.agent-chat__voice-turns {
@ -2196,15 +2181,107 @@ openclaw-chat-page {
border-color: color-mix(in srgb, var(--accent) 70%, transparent);
}
/* Live voice input: the level meter itself is the control. Keeping it accent
(vs the danger stop square) is what disambiguates the two controls when a
run is abortable while recording; the stop glyph only crossfades in on
hover/focus so the resting state stays a pure waveform. */
.chat-send-btn--voice-live {
position: relative;
width: auto;
min-width: 64px;
padding: 0 14px;
border-radius: var(--radius-full);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.chat-send-btn--voice-live::after {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 30%, transparent);
opacity: 0.5;
animation: chat-voice-live-breathe 2.8s ease-in-out infinite;
pointer-events: none;
}
.chat-send-btn--voice-live:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
.chat-send-btn--voice-live .agent-chat__voice-activity {
transition: opacity var(--duration-fast) ease;
}
.chat-send-btn--voice-live:hover:not(:disabled) .agent-chat__voice-activity,
.chat-send-btn--voice-live:focus-visible .agent-chat__voice-activity {
opacity: 0.25;
}
.chat-send-btn__voice-stop-glyph {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.chat-send-btn--voice-live .chat-send-btn__voice-stop-glyph svg {
width: 12px;
height: 12px;
fill: currentColor;
stroke: none;
}
.chat-send-btn--voice-live:hover:not(:disabled) .chat-send-btn__voice-stop-glyph,
.chat-send-btn--voice-live:focus-visible .chat-send-btn__voice-stop-glyph {
opacity: 1;
}
@keyframes chat-voice-live-breathe {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 0.8;
}
}
/* Errored Talk session: the meter is gone, so the stop glyph stays visible
and the glow calms down; the alert row above owns the error message. */
.chat-send-btn--voice-error .chat-send-btn__voice-stop-glyph {
opacity: 1;
}
.chat-send-btn--voice-error::after {
animation: none;
opacity: 0.25;
}
/* Filled square + soft danger fill so Stop generating reads as the one
destructive control; the outline-only square was ambiguous next to other
36px icon buttons. */
.chat-send-btn--stop {
border-color: var(--danger);
background: transparent;
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
background: color-mix(in srgb, var(--danger) 10%, transparent);
color: var(--danger);
}
.chat-send-btn--stop svg {
width: 18px;
height: 18px;
fill: currentColor;
stroke: none;
}
.chat-send-btn--stop:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--danger) 82%, #fff);
background: color-mix(in srgb, var(--danger) 11%, transparent);
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: color-mix(in srgb, var(--danger) 82%, #fff);
}
@ -3584,6 +3661,13 @@ openclaw-chat-page {
height: 44px;
}
/* Keep the live voice pill wide enough for its 7-bar meter; the generic
44px square above would crush it at phone widths. */
.chat-send-btn--voice-live {
width: auto;
min-width: 64px;
}
.agent-chat__composer-shell {
width: calc(100% - 8px);
margin: 6px 4px calc(14px + var(--safe-area-bottom));