mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
chore: move chart release note to PR
This commit is contained in:
parent
2bae3e22cb
commit
4f92d51287
54 changed files with 1638 additions and 2504 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
@ -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(
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
|
|||
"bonjour",
|
||||
"browser",
|
||||
"canvas",
|
||||
"codex-supervisor",
|
||||
"device-pair",
|
||||
"diagnostics-otel",
|
||||
"diagnostics-prometheus",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
71
src/plugins/doctor-state-migration-fs.test.ts
Normal file
71
src/plugins/doctor-state-migration-fs.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
|
|
|
|||
14
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
14
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue