diff --git a/apps/.i18n/apple-translation-contradictions.json b/apps/.i18n/apple-translation-contradictions.json index 99d056ece49c..1cd929526d43 100644 --- a/apps/.i18n/apple-translation-contradictions.json +++ b/apps/.i18n/apple-translation-contradictions.json @@ -5409,6 +5409,22 @@ "未激活" ] }, + { + "locale": "es", + "source": "Not selected", + "translations": [ + "No seleccionada", + "No seleccionado" + ] + }, + { + "locale": "sv", + "source": "Not selected", + "translations": [ + "Inte vald", + "Inte valt" + ] + }, { "locale": "de", "source": "Notifications", @@ -7584,6 +7600,30 @@ "Захищено (TLS)" ] }, + { + "locale": "es", + "source": "Selected", + "translations": [ + "Seleccionada", + "Seleccionado" + ] + }, + { + "locale": "fa", + "source": "Selected", + "translations": [ + "انتخاب شده", + "انتخاب‌شده" + ] + }, + { + "locale": "sv", + "source": "Selected", + "translations": [ + "Vald", + "Valt" + ] + }, { "locale": "tr", "source": "Selected", diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 617be375b8a6..6f245c55615e 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -539,7 +539,7 @@ }, { "kind": "ui-state-text", - "line": 463, + "line": 464, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Searching…", "surface": "android", @@ -547,7 +547,7 @@ }, { "kind": "ui-state-text", - "line": 585, + "line": 586, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -555,7 +555,7 @@ }, { "kind": "ui-state-text", - "line": 599, + "line": 600, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -691,7 +691,7 @@ }, { "kind": "ui-call", - "line": 168, + "line": 169, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.", "surface": "android", @@ -699,7 +699,7 @@ }, { "kind": "ui-call", - "line": 170, + "line": 171, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The Gateway still shows this approval as pending. Review it before trying again.", "surface": "android", @@ -707,7 +707,7 @@ }, { "kind": "ui-call", - "line": 172, + "line": 173, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approval details. Refresh and try again.", "surface": "android", @@ -715,7 +715,7 @@ }, { "kind": "ui-call", - "line": 174, + "line": 175, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approvals.", "surface": "android", @@ -723,7 +723,7 @@ }, { "kind": "ui-call", - "line": 176, + "line": 177, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not resolve approval. Refresh and try again.", "surface": "android", @@ -731,7 +731,7 @@ }, { "kind": "ui-call", - "line": 191, + "line": 192, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal applied.", "surface": "android", @@ -739,7 +739,7 @@ }, { "kind": "ui-call", - "line": 191, + "line": 192, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "apply", "surface": "android", @@ -747,7 +747,7 @@ }, { "kind": "ui-call", - "line": 192, + "line": 193, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal rejected.", "surface": "android", @@ -755,7 +755,7 @@ }, { "kind": "ui-call", - "line": 192, + "line": 193, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "reject", "surface": "android", @@ -763,7 +763,7 @@ }, { "kind": "ui-call", - "line": 193, + "line": 194, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal quarantined.", "surface": "android", @@ -771,7 +771,7 @@ }, { "kind": "ui-call", - "line": 193, + "line": 194, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "quarantine", "surface": "android", @@ -779,7 +779,7 @@ }, { "kind": "ui-call", - "line": 200, + "line": 201, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "unknown", "surface": "android", @@ -787,7 +787,7 @@ }, { "kind": "ui-call", - "line": 201, + "line": 202, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned status '$statusLabel' after ${action.verb}.", "surface": "android", @@ -795,7 +795,7 @@ }, { "kind": "ui-call", - "line": 209, + "line": 210, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not ${action.verb} Skill Workshop proposal.", "surface": "android", @@ -803,7 +803,7 @@ }, { "kind": "ui-call", - "line": 391, + "line": 392, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (node offline)", "surface": "android", @@ -811,7 +811,7 @@ }, { "kind": "ui-call", - "line": 392, + "line": 393, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator offline)", "surface": "android", @@ -819,7 +819,7 @@ }, { "kind": "ui-call", - "line": 394, + "line": 395, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting…", "surface": "android", @@ -827,7 +827,7 @@ }, { "kind": "ui-call", - "line": 402, + "line": 403, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator: $operator)", "surface": "android", @@ -835,7 +835,7 @@ }, { "kind": "ui-call", - "line": 1943, + "line": 1944, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation already has a queued run.", "surface": "android", @@ -843,7 +843,7 @@ }, { "kind": "ui-call", - "line": 1973, + "line": 1974, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation run queued.", "surface": "android", @@ -851,7 +851,7 @@ }, { "kind": "ui-call", - "line": 1973, + "line": 1974, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation started.", "surface": "android", @@ -859,7 +859,7 @@ }, { "kind": "ui-call", - "line": 1986, + "line": 1987, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway rejected the automation run.", "surface": "android", @@ -867,7 +867,7 @@ }, { "kind": "ui-call", - "line": 2017, + "line": 2018, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation enabled.", "surface": "android", @@ -875,7 +875,7 @@ }, { "kind": "ui-call", - "line": 2017, + "line": 2018, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation paused.", "surface": "android", @@ -883,7 +883,7 @@ }, { "kind": "ui-call", - "line": 2039, + "line": 2040, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation changed on the gateway. Review the latest version before saving again.", "surface": "android", @@ -891,7 +891,7 @@ }, { "kind": "ui-call", - "line": 2045, + "line": 2046, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation updated.", "surface": "android", @@ -899,7 +899,7 @@ }, { "kind": "ui-call", - "line": 2060, + "line": 2061, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation deleted.", "surface": "android", @@ -907,7 +907,7 @@ }, { "kind": "ui-call", - "line": 2265, + "line": 2266, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Node offline. Reconnect and retry.", "surface": "android", @@ -915,7 +915,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 2276, + "line": 2277, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Restore canvas now for session=$sessionKey source=$source. If existing A2UI state exists, replay it immediately. If not, create and render a compact mobile-friendly dashboard in Canvas.", "surface": "android", @@ -923,7 +923,7 @@ }, { "kind": "ui-call", - "line": 2297, + "line": 2298, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed to request restore. Tap to retry.", "surface": "android", @@ -931,7 +931,7 @@ }, { "kind": "ui-call", - "line": 2308, + "line": 2309, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No canvas update yet. Tap to retry.", "surface": "android", @@ -939,7 +939,7 @@ }, { "kind": "ui-call", - "line": 3206, + "line": 3213, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to a Gateway to save wake words", "surface": "android", @@ -947,7 +947,7 @@ }, { "kind": "ui-call", - "line": 3234, + "line": 3241, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Wake words saved", "surface": "android", @@ -955,7 +955,7 @@ }, { "kind": "ui-call", - "line": 3243, + "line": 3250, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not save wake words", "surface": "android", @@ -963,7 +963,7 @@ }, { "kind": "ui-call", - "line": 3930, + "line": 3937, "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", @@ -971,7 +971,7 @@ }, { "kind": "ui-call", - "line": 3934, + "line": 3941, "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", @@ -979,7 +979,7 @@ }, { "kind": "ui-call", - "line": 3938, + "line": 3945, "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", @@ -987,7 +987,7 @@ }, { "kind": "ui-named-argument", - "line": 3982, + "line": 3989, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connecting…", "surface": "android", @@ -995,7 +995,7 @@ }, { "kind": "ui-named-argument", - "line": 4165, + "line": 4172, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -1003,7 +1003,7 @@ }, { "kind": "ui-call", - "line": 4899, + "line": 4906, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider catalog.", "surface": "android", @@ -1011,7 +1011,7 @@ }, { "kind": "ui-call", - "line": 4930, + "line": 4937, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Update your Gateway to view provider model config.", "surface": "android", @@ -1019,7 +1019,7 @@ }, { "kind": "ui-call", - "line": 4932, + "line": 4939, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider model config.", "surface": "android", @@ -1027,7 +1027,7 @@ }, { "kind": "ui-call", - "line": 4948, + "line": 4955, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Provider models loaded, but readiness is unavailable.", "surface": "android", @@ -1035,7 +1035,7 @@ }, { "kind": "ui-call", - "line": 5035, + "line": 5042, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automations.", "surface": "android", @@ -1043,7 +1043,7 @@ }, { "kind": "ui-call", - "line": 5121, + "line": 5128, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automations.", "surface": "android", @@ -1051,7 +1051,7 @@ }, { "kind": "ui-call", - "line": 5131, + "line": 5138, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned an invalid automation.", "surface": "android", @@ -1059,7 +1059,7 @@ }, { "kind": "ui-call", - "line": 5135, + "line": 5142, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation.", "surface": "android", @@ -1067,7 +1067,7 @@ }, { "kind": "ui-call", - "line": 5147, + "line": 5154, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automation run history.", "surface": "android", @@ -1075,7 +1075,7 @@ }, { "kind": "ui-call", - "line": 5178, + "line": 5185, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation run history.", "surface": "android", @@ -1083,7 +1083,7 @@ }, { "kind": "ui-call", - "line": 5195, + "line": 5202, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron changes require operator.admin access.", "surface": "android", @@ -1091,7 +1091,7 @@ }, { "kind": "ui-call", - "line": 5204, + "line": 5211, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to manage automations.", "surface": "android", @@ -1099,7 +1099,7 @@ }, { "kind": "ui-call", - "line": 5216, + "line": 5223, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Another cron action is still finishing.", "surface": "android", @@ -1107,7 +1107,7 @@ }, { "kind": "ui-call", - "line": 5263, + "line": 5270, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron action failed.", "surface": "android", @@ -1115,7 +1115,7 @@ }, { "kind": "ui-call", - "line": 5390, + "line": 5397, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load usage.", "surface": "android", @@ -1123,7 +1123,7 @@ }, { "kind": "ui-call", - "line": 5425, + "line": 5432, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load skills.", "surface": "android", @@ -1131,7 +1131,7 @@ }, { "kind": "ui-call", - "line": 5438, + "line": 5445, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update skills.", "surface": "android", @@ -1139,7 +1139,7 @@ }, { "kind": "ui-call", - "line": 5442, + "line": 5449, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to update skills.", "surface": "android", @@ -1147,7 +1147,7 @@ }, { "kind": "conditional-branch", - "line": 5457, + "line": 5464, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not disable skill.", "surface": "android", @@ -1155,7 +1155,7 @@ }, { "kind": "conditional-branch", - "line": 5457, + "line": 5464, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not enable skill.", "surface": "android", @@ -1163,7 +1163,7 @@ }, { "kind": "ui-call", - "line": 5475, + "line": 5482, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to search ClawHub skills.", "surface": "android", @@ -1171,7 +1171,7 @@ }, { "kind": "ui-call", - "line": 5521, + "line": 5528, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not search ClawHub skills.", "surface": "android", @@ -1179,7 +1179,7 @@ }, { "kind": "ui-call", - "line": 5534, + "line": 5541, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect ClawHub skills.", "surface": "android", @@ -1187,7 +1187,7 @@ }, { "kind": "conditional-branch", - "line": 5567, + "line": 5574, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "ClawHub did not return an installable version for ${skill.slug}.", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "ui-call", - "line": 5583, + "line": 5590, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load ClawHub details for ${skill.slug}.", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "ui-call", - "line": 5599, + "line": 5606, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to install ClawHub skills.", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "ui-call", - "line": 5615, + "line": 5622, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to install ClawHub skills.", "surface": "android", @@ -1219,7 +1219,7 @@ }, { "kind": "conditional-branch", - "line": 5705, + "line": 5712, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Installed $slug.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "ui-call", - "line": 5712, + "line": 5719, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not install ${slug} from ClawHub.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "ui-call", - "line": 5752, + "line": 5759, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to load Skill Workshop proposals.", "surface": "android", @@ -1243,7 +1243,7 @@ }, { "kind": "ui-call", - "line": 5792, + "line": 5799, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load Skill Workshop proposals.", "surface": "android", @@ -1251,7 +1251,7 @@ }, { "kind": "ui-call", - "line": 5812, + "line": 5819, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect Skill Workshop proposals.", "surface": "android", @@ -1259,7 +1259,7 @@ }, { "kind": "ui-call", - "line": 5864, + "line": 5871, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not inspect Skill Workshop proposal.", "surface": "android", @@ -1267,7 +1267,7 @@ }, { "kind": "ui-call", - "line": 5884, + "line": 5891, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Skill Workshop proposal actions require operator.admin scope.", "surface": "android", @@ -1275,7 +1275,7 @@ }, { "kind": "ui-call", - "line": 5889, + "line": 5896, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update Skill Workshop proposals.", "surface": "android", @@ -1283,7 +1283,7 @@ }, { "kind": "ui-named-argument", - "line": 6048, + "line": 6055, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected", "surface": "android", @@ -1291,7 +1291,7 @@ }, { "kind": "ui-call", - "line": 6074, + "line": 6081, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load nodes and devices.", "surface": "android", @@ -1299,7 +1299,7 @@ }, { "kind": "ui-call", - "line": 6732, + "line": 6739, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load channels.", "surface": "android", @@ -1307,7 +1307,7 @@ }, { "kind": "ui-call", - "line": 6762, + "line": 6769, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load dreaming.", "surface": "android", @@ -1315,7 +1315,7 @@ }, { "kind": "ui-call", - "line": 6799, + "line": 6806, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load gateway logs.", "surface": "android", @@ -1323,7 +1323,7 @@ }, { "kind": "ui-call", - "line": 7256, + "line": 7263, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "One time", "surface": "android", @@ -1331,7 +1331,7 @@ }, { "kind": "ui-call", - "line": 7265, + "line": 7272, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron", "surface": "android", @@ -1339,7 +1339,7 @@ }, { "kind": "ui-call", - "line": 7266, + "line": 7273, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Scheduled", "surface": "android", @@ -1347,7 +1347,7 @@ }, { "kind": "ui-call", - "line": 7274, + "line": 7281, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${days}d", "surface": "android", @@ -1355,7 +1355,7 @@ }, { "kind": "ui-call", - "line": 7275, + "line": 7282, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${hours}h", "surface": "android", @@ -1363,7 +1363,7 @@ }, { "kind": "ui-call", - "line": 7276, + "line": 7283, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${minutes}m", "surface": "android", @@ -1371,7 +1371,7 @@ }, { "kind": "ui-call", - "line": 7277, + "line": 7284, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Repeating", "surface": "android", @@ -1379,7 +1379,7 @@ }, { "kind": "ui-call", - "line": 7293, + "line": 7300, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No prompt", "surface": "android", @@ -1387,7 +1387,7 @@ }, { "kind": "ui-call", - "line": 7310, + "line": 7317, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway", "surface": "android", @@ -1395,7 +1395,7 @@ }, { "kind": "ui-call", - "line": 7318, + "line": 7325, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected to $gatewayLabel", "surface": "android", @@ -1403,7 +1403,7 @@ }, { "kind": "ui-call", - "line": 7319, + "line": 7326, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your agents are ready", "surface": "android", @@ -1411,7 +1411,7 @@ }, { "kind": "ui-call", - "line": 7321, + "line": 7328, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", "surface": "android", @@ -1419,7 +1419,7 @@ }, { "kind": "ui-call", - "line": 7325, + "line": 7332, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Selected on this phone", "surface": "android", @@ -1427,7 +1427,7 @@ }, { "kind": "ui-call", - "line": 7328, + "line": 7335, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The overview refreshes on reconnect and when this screen opens.", "surface": "android", @@ -1435,7 +1435,7 @@ }, { "kind": "ui-call", - "line": 7333, + "line": 7340, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting", "surface": "android", @@ -1443,7 +1443,7 @@ }, { "kind": "ui-call", - "line": 7334, + "line": 7341, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "OpenClaw is syncing back up", "surface": "android", @@ -1451,7 +1451,7 @@ }, { "kind": "ui-call", - "line": 7336, + "line": 7343, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", "surface": "android", @@ -1459,7 +1459,7 @@ }, { "kind": "ui-call", - "line": 7340, + "line": 7347, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway session in progress", "surface": "android", @@ -1467,7 +1467,7 @@ }, { "kind": "ui-call", - "line": 7343, + "line": 7350, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "If the gateway is reachable, reconnect should complete without intervention.", "surface": "android", @@ -1475,7 +1475,7 @@ }, { "kind": "ui-call", - "line": 7348, + "line": 7355, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -1483,7 +1483,7 @@ }, { "kind": "ui-call", - "line": 7349, + "line": 7356, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your phone stays quiet until it is needed", "surface": "android", @@ -1491,7 +1491,7 @@ }, { "kind": "ui-call", - "line": 7351, + "line": 7358, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", "surface": "android", @@ -1499,7 +1499,7 @@ }, { "kind": "ui-call", - "line": 7355, + "line": 7362, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to load your agents", "surface": "android", @@ -1507,7 +1507,7 @@ }, { "kind": "ui-call", - "line": 7358, + "line": 7365, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", "surface": "android", @@ -1515,7 +1515,7 @@ }, { "kind": "ui-call", - "line": 7390, + "line": 7397, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Main", "surface": "android", @@ -1523,7 +1523,7 @@ }, { "kind": "ui-call", - "line": 7405, + "line": 7412, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Active on this phone", "surface": "android", @@ -1531,7 +1531,7 @@ }, { "kind": "ui-call", - "line": 7406, + "line": 7413, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Default agent", "surface": "android", @@ -1539,7 +1539,7 @@ }, { "kind": "ui-call", - "line": 7407, + "line": 7414, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Ready", "surface": "android", @@ -1547,7 +1547,7 @@ }, { "kind": "ui-call", - "line": 7966, + "line": 7973, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", @@ -1723,7 +1723,7 @@ }, { "kind": "ui-call", - "line": 1017, + "line": 1035, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Wait for the current response to finish before starting a new chat.", "surface": "android", @@ -1731,7 +1731,7 @@ }, { "kind": "ui-call", - "line": 1149, + "line": 1167, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update model.", "surface": "android", @@ -1739,7 +1739,7 @@ }, { "kind": "ui-call", - "line": 1214, + "line": 1232, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update thinking level.", "surface": "android", @@ -1747,7 +1747,7 @@ }, { "kind": "ui-call", - "line": 1707, + "line": 1725, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed before the run started; try again.", "surface": "android", @@ -1755,7 +1755,7 @@ }, { "kind": "ui-call", - "line": 2743, + "line": 3007, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not stage an attachment for sending.", "surface": "android", @@ -1763,7 +1763,7 @@ }, { "kind": "ui-call", - "line": 2776, + "line": 3040, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.", "surface": "android", @@ -1771,7 +1771,7 @@ }, { "kind": "ui-call", - "line": 2782, + "line": 3046, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Attachments are too large to queue for one message; remove some and try again.", "surface": "android", @@ -1779,7 +1779,7 @@ }, { "kind": "ui-call", - "line": 2788, + "line": 3052, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline attachment storage is full; delete queued items first.", "surface": "android", @@ -1787,7 +1787,7 @@ }, { "kind": "ui-call", - "line": 2793, + "line": 3057, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Gateway health not OK; cannot send", "surface": "android", @@ -1795,7 +1795,7 @@ }, { "kind": "ui-call", - "line": 2800, + "line": 3064, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not queue message for later delivery.", "surface": "android", @@ -1803,7 +1803,7 @@ }, { "kind": "ui-call", - "line": 3517, + "line": 3781, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed", "surface": "android", @@ -1811,7 +1811,7 @@ }, { "kind": "ui-call", - "line": 3655, + "line": 3919, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Event stream interrupted; try refreshing.", "surface": "android", @@ -1819,7 +1819,7 @@ }, { "kind": "ui-call", - "line": 3792, + "line": 4056, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out waiting for a reply; try again or refresh.", "surface": "android", @@ -1827,7 +1827,7 @@ }, { "kind": "ui-call", - "line": 4002, + "line": 4266, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out confirming the sent message; refresh to check delivery.", "surface": "android", @@ -1843,7 +1843,7 @@ }, { "kind": "conditional-branch", - "line": 1529, + "line": 1530, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Connecting…", "surface": "android", @@ -1851,7 +1851,7 @@ }, { "kind": "conditional-branch", - "line": 1529, + "line": 1530, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Reconnecting…", "surface": "android", @@ -11747,7 +11747,87 @@ }, { "kind": "ui-call", - "line": 289, + "line": 131, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Other answer", + "surface": "android", + "id": "native.android.bf40f9f9a508155d" + }, + { + "kind": "ui-call", + "line": 160, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Submit", + "surface": "android", + "id": "native.android.fa81739f52e7697f" + }, + { + "kind": "ui-call", + "line": 170, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Answered", + "surface": "android", + "id": "native.android.f68cd7a84be4089e" + }, + { + "kind": "ui-call", + "line": 171, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Answered elsewhere", + "surface": "android", + "id": "native.android.a0dc883947c2f980" + }, + { + "kind": "ui-call", + "line": 172, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Expired", + "surface": "android", + "id": "native.android.1a0e6b33059a63aa" + }, + { + "kind": "ui-call", + "line": 173, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Cancelled", + "surface": "android", + "id": "native.android.d6d8fc83cec68418" + }, + { + "kind": "ui-call", + "line": 174, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Pending", + "surface": "android", + "id": "native.android.73f64d0d54b7f9c0" + }, + { + "kind": "ui-call", + "line": 175, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Submitting…", + "surface": "android", + "id": "native.android.92dfa7979d04989c" + }, + { + "kind": "ui-call", + "line": 192, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "surface": "android", + "id": "native.android.c5e9ee99f9d9c612" + }, + { + "kind": "ui-call", + "line": 194, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt", + "source": "Expires in ${seconds}s", + "surface": "android", + "id": "native.android.2ca602fbdf96f1e7" + }, + { + "kind": "ui-call", + "line": 292, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Model", "surface": "android", @@ -11755,7 +11835,7 @@ }, { "kind": "ui-call", - "line": 374, + "line": 377, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Some shared images were omitted or could not be added.", "surface": "android", @@ -11763,7 +11843,7 @@ }, { "kind": "ui-call", - "line": 515, + "line": 518, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat needs attention", "surface": "android", @@ -11771,7 +11851,7 @@ }, { "kind": "ui-call", - "line": 762, + "line": 767, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "All", "surface": "android", @@ -11779,7 +11859,7 @@ }, { "kind": "ui-call", - "line": 844, + "line": 849, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Working", "surface": "android", @@ -11787,7 +11867,7 @@ }, { "kind": "ui-call", - "line": 845, + "line": 850, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready", "surface": "android", @@ -11795,7 +11875,7 @@ }, { "kind": "ui-call", - "line": 846, + "line": 851, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Offline", "surface": "android", @@ -11803,7 +11883,7 @@ }, { "kind": "ui-call", - "line": 859, + "line": 864, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat actions", "surface": "android", @@ -11811,7 +11891,7 @@ }, { "kind": "ui-call", - "line": 864, + "line": 869, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Refresh chat", "surface": "android", @@ -11819,7 +11899,7 @@ }, { "kind": "ui-call", - "line": 872, + "line": 877, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Background tasks", "surface": "android", @@ -11827,7 +11907,7 @@ }, { "kind": "ui-call", - "line": 893, + "line": 898, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat", "surface": "android", @@ -11835,7 +11915,7 @@ }, { "kind": "ui-call", - "line": 1036, + "line": 1044, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Messages to recover", "surface": "android", @@ -11843,7 +11923,7 @@ }, { "kind": "ui-call", - "line": 1038, + "line": 1046, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.", "surface": "android", @@ -11851,7 +11931,7 @@ }, { "kind": "ui-call", - "line": 1064, + "line": 1074, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Loading session", "surface": "android", @@ -11859,7 +11939,7 @@ }, { "kind": "ui-call", - "line": 1091, + "line": 1101, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Jump to latest", "surface": "android", @@ -11867,7 +11947,7 @@ }, { "kind": "ui-call", - "line": 1117, + "line": 1127, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready when you are", "surface": "android", @@ -11875,7 +11955,7 @@ }, { "kind": "ui-call", - "line": 1121, + "line": 1131, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Start with a prompt, or use voice.", "surface": "android", @@ -11883,7 +11963,7 @@ }, { "kind": "ui-call", - "line": 1123, + "line": 1133, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Use the recovery options below to reconnect.", "surface": "android", @@ -11891,7 +11971,7 @@ }, { "kind": "ui-call", - "line": 1125, + "line": 1135, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat is checking Gateway health.", "surface": "android", @@ -11899,7 +11979,7 @@ }, { "kind": "ui-call", - "line": 1148, + "line": 1158, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Fix connection", "surface": "android", @@ -11907,7 +11987,7 @@ }, { "kind": "ui-call", - "line": 1149, + "line": 1159, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Copy diagnostics", "surface": "android", @@ -11915,7 +11995,7 @@ }, { "kind": "ui-call", - "line": 1208, + "line": 1218, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Catch me up", "surface": "android", @@ -11923,7 +12003,7 @@ }, { "kind": "ui-call", - "line": 1209, + "line": 1219, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Summarize recent sessions and next steps.", "surface": "android", @@ -11931,7 +12011,7 @@ }, { "kind": "ui-call", - "line": 1210, + "line": 1220, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Catch me up on my recent OpenClaw sessions and suggest next steps.", "surface": "android", @@ -11939,7 +12019,7 @@ }, { "kind": "ui-call", - "line": 1214, + "line": 1224, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Plan the work", "surface": "android", @@ -11947,7 +12027,7 @@ }, { "kind": "ui-call", - "line": 1215, + "line": 1225, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Turn a goal into an actionable checklist.", "surface": "android", @@ -11955,7 +12035,7 @@ }, { "kind": "ui-call", - "line": 1216, + "line": 1226, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Help me turn this goal into a practical checklist: ", "surface": "android", @@ -11963,7 +12043,7 @@ }, { "kind": "ui-call", - "line": 1220, + "line": 1230, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Use this phone", "surface": "android", @@ -11971,7 +12051,7 @@ }, { "kind": "ui-call", - "line": 1221, + "line": 1231, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ask OpenClaw to use Android capabilities.", "surface": "android", @@ -11979,7 +12059,7 @@ }, { "kind": "ui-call", - "line": 1222, + "line": 1232, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "What can you help me do from this phone right now?", "surface": "android", @@ -11987,7 +12067,7 @@ }, { "kind": "ui-call", - "line": 1287, + "line": 1297, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw · Live", "surface": "android", @@ -11995,7 +12075,7 @@ }, { "kind": "ui-call", - "line": 1288, + "line": 1298, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "You", "surface": "android", @@ -12003,7 +12083,7 @@ }, { "kind": "ui-call", - "line": 1289, + "line": 1299, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "System", "surface": "android", @@ -12011,7 +12091,7 @@ }, { "kind": "ui-call", - "line": 1290, + "line": 1300, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw", "surface": "android", @@ -12019,7 +12099,7 @@ }, { "kind": "ui-call", - "line": 1310, + "line": 1320, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attachment", "surface": "android", @@ -12027,7 +12107,7 @@ }, { "kind": "ui-call", - "line": 1363, + "line": 1373, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Preparing audio…", "surface": "android", @@ -12035,7 +12115,7 @@ }, { "kind": "ui-call", - "line": 1363, + "line": 1373, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Speaking…", "surface": "android", @@ -12043,7 +12123,7 @@ }, { "kind": "ui-call", - "line": 1384, + "line": 1394, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Tools running", "surface": "android", @@ -12051,7 +12131,7 @@ }, { "kind": "ui-call", - "line": 1386, + "line": 1396, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is working", "surface": "android", @@ -12059,7 +12139,7 @@ }, { "kind": "ui-call", - "line": 1389, + "line": 1399, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "+${toolCalls.size - 4} more", "surface": "android", @@ -12067,7 +12147,7 @@ }, { "kind": "ui-call", - "line": 1399, + "line": 1409, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Thinking", "surface": "android", @@ -12075,7 +12155,7 @@ }, { "kind": "ui-call", - "line": 1400, + "line": 1410, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is preparing a response.", "surface": "android", @@ -12083,7 +12163,7 @@ }, { "kind": "ui-named-argument", - "line": 1465, + "line": 1475, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$completedCount/${steps.size}", "surface": "android", @@ -12091,7 +12171,7 @@ }, { "kind": "ui-call", - "line": 1472, + "line": 1482, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Collapse plan checklist", "surface": "android", @@ -12099,7 +12179,7 @@ }, { "kind": "ui-call", - "line": 1472, + "line": 1482, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Expand plan checklist", "surface": "android", @@ -12107,7 +12187,7 @@ }, { "kind": "ui-call", - "line": 1596, + "line": 1606, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Dismiss shared-image warning", "surface": "android", @@ -12115,7 +12195,7 @@ }, { "kind": "ui-call", - "line": 1697, + "line": 1707, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Stop", "surface": "android", @@ -12123,7 +12203,7 @@ }, { "kind": "ui-call", - "line": 1795, + "line": 1805, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Default", "surface": "android", @@ -12131,7 +12211,7 @@ }, { "kind": "ui-call", - "line": 1861, + "line": 1871, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Pin model", "surface": "android", @@ -12139,7 +12219,7 @@ }, { "kind": "ui-call", - "line": 1861, + "line": 1871, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Unpin model", "surface": "android", @@ -12147,7 +12227,7 @@ }, { "kind": "ui-call", - "line": 1878, + "line": 1888, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "No commands found", "surface": "android", @@ -12155,7 +12235,7 @@ }, { "kind": "ui-call", - "line": 1920, + "line": 1930, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Command", "surface": "android", @@ -12163,7 +12243,7 @@ }, { "kind": "ui-call", - "line": 1940, + "line": 1950, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway offline", "surface": "android", @@ -12171,7 +12251,7 @@ }, { "kind": "ui-call", - "line": 1986, + "line": 1996, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Close thinking level selector", "surface": "android", @@ -12179,7 +12259,7 @@ }, { "kind": "ui-call", - "line": 1986, + "line": 1996, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Open thinking level selector", "surface": "android", @@ -12187,7 +12267,7 @@ }, { "kind": "ui-call", - "line": 2046, + "line": 2056, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attach image", "surface": "android", @@ -12195,7 +12275,7 @@ }, { "kind": "ui-call", - "line": 2081, + "line": 2091, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Message OpenClaw", "surface": "android", @@ -12203,7 +12283,7 @@ }, { "kind": "ui-call", - "line": 2097, + "line": 2107, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Open voice", "surface": "android", @@ -12211,7 +12291,7 @@ }, { "kind": "ui-call", - "line": 2137, + "line": 2147, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Voice note · ${formatVoiceNoteDuration(duration)}", "surface": "android", @@ -12219,7 +12299,7 @@ }, { "kind": "ui-call", - "line": 2146, + "line": 2156, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Remove attachment", "surface": "android", @@ -12227,7 +12307,7 @@ }, { "kind": "ui-call", - "line": 2158, + "line": 2168, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "New chat", "surface": "android", @@ -12235,7 +12315,7 @@ }, { "kind": "ui-call", - "line": 2167, + "line": 2177, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Main", "surface": "android", @@ -12243,7 +12323,7 @@ }, { "kind": "ui-call", - "line": 2168, + "line": 2178, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Current", "surface": "android", @@ -12251,7 +12331,7 @@ }, { "kind": "ui-call", - "line": 2175, + "line": 2185, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$emoji $name", "surface": "android", @@ -12259,7 +12339,7 @@ }, { "kind": "ui-call", - "line": 2234, + "line": 2244, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Send", "surface": "android", @@ -12267,7 +12347,7 @@ }, { "kind": "ui-call", - "line": 2245, + "line": 2255, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat is still checking Gateway health.", "surface": "android", @@ -12275,7 +12355,7 @@ }, { "kind": "ui-call", - "line": 2246, + "line": 2256, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway is offline. Fix the connection below or copy diagnostics.", "surface": "android", @@ -12283,7 +12363,7 @@ }, { "kind": "ui-call", - "line": 2247, + "line": 2257, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway authentication needs attention.", "surface": "android", @@ -12291,7 +12371,7 @@ }, { "kind": "ui-call", - "line": 2266, + "line": 2276, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Context ${(it * 100).roundToInt()}%", "surface": "android", @@ -12299,7 +12379,7 @@ }, { "kind": "ui-call", - "line": 2267, + "line": 2277, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Context --", "surface": "android", @@ -12307,7 +12387,7 @@ }, { "kind": "ui-call", - "line": 2268, + "line": 2278, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}", "surface": "android", @@ -12315,7 +12395,7 @@ }, { "kind": "ui-call", - "line": 2307, + "line": 2317, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Off", "surface": "android", @@ -12323,7 +12403,7 @@ }, { "kind": "ui-call", - "line": 2308, + "line": 2318, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Minimal", "surface": "android", @@ -12331,7 +12411,7 @@ }, { "kind": "ui-call", - "line": 2309, + "line": 2319, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Low", "surface": "android", @@ -12339,7 +12419,7 @@ }, { "kind": "ui-call", - "line": 2310, + "line": 2320, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Medium", "surface": "android", @@ -12347,7 +12427,7 @@ }, { "kind": "ui-call", - "line": 2311, + "line": 2321, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "High", "surface": "android", @@ -12355,7 +12435,7 @@ }, { "kind": "ui-call", - "line": 2312, + "line": 2322, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Xhigh", "surface": "android", @@ -12363,7 +12443,7 @@ }, { "kind": "ui-call", - "line": 2313, + "line": 2323, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Adaptive", "surface": "android", @@ -12371,7 +12451,7 @@ }, { "kind": "ui-call", - "line": 2314, + "line": 2324, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Max", "surface": "android", @@ -35899,7 +35979,7 @@ }, { "kind": "ui-localized-call", - "line": 748, + "line": 758, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode off", "surface": "apple", @@ -35907,7 +35987,7 @@ }, { "kind": "ui-localized-call", - "line": 749, + "line": 759, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode paused", "surface": "apple", @@ -35915,7 +35995,7 @@ }, { "kind": "ui-localized-call", - "line": 751, + "line": 761, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode ready", "surface": "apple", @@ -35923,7 +36003,7 @@ }, { "kind": "ui-localized-call", - "line": 752, + "line": 762, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Listening", "surface": "apple", @@ -35931,7 +36011,7 @@ }, { "kind": "ui-localized-call", - "line": 753, + "line": 763, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Thinking", "surface": "apple", @@ -35939,7 +36019,7 @@ }, { "kind": "ui-localized-call", - "line": 754, + "line": 764, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Speaking", "surface": "apple", @@ -35947,7 +36027,7 @@ }, { "kind": "ui-localized-call", - "line": 758, + "line": 768, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "What would you like to work on?", "surface": "apple", @@ -35955,7 +36035,7 @@ }, { "kind": "ui-localized-call", - "line": 762, + "line": 772, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Check OpenClaw status", "surface": "apple", @@ -35963,7 +36043,7 @@ }, { "kind": "ui-localized-call", - "line": 763, + "line": 773, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Summarize the current OpenClaw status and tell me what needs attention.", "surface": "apple", @@ -35971,7 +36051,7 @@ }, { "kind": "ui-localized-call", - "line": 766, + "line": 776, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "What can you do?", "surface": "apple", @@ -35979,7 +36059,7 @@ }, { "kind": "ui-localized-call", - "line": 767, + "line": 777, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Show me what you can help with on this Mac right now.", "surface": "apple", @@ -35987,7 +36067,7 @@ }, { "kind": "ui-localized-call", - "line": 770, + "line": 780, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Catch me up", "surface": "apple", @@ -35995,7 +36075,7 @@ }, { "kind": "ui-localized-call", - "line": 771, + "line": 781, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Summarize what happened in my sessions since yesterday.", "surface": "apple", @@ -36657,6 +36737,62 @@ "surface": "apple", "id": "native.apple.b6b3b9af4c618060" }, + { + "kind": "ui-call", + "line": 237, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", + "source": "Other answer", + "surface": "apple", + "id": "native.apple.da4494f9e875169d" + }, + { + "kind": "conditional-branch", + "line": 276, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", + "source": "Not selected", + "surface": "apple", + "id": "native.apple.e9c3855562d8e4e9" + }, + { + "kind": "conditional-branch", + "line": 276, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", + "source": "Selected", + "surface": "apple", + "id": "native.apple.bd4335213464d4be" + }, + { + "kind": "conditional-branch", + "line": 288, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", + "source": "Submit", + "surface": "apple", + "id": "native.apple.e3095b651fc46b52" + }, + { + "kind": "conditional-branch", + "line": 288, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", + "source": "Submitting…", + "surface": "apple", + "id": "native.apple.6c1adc16e0a846e5" + }, + { + "kind": "conditional-branch", + "line": 309, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "surface": "apple", + "id": "native.apple.698bdb0d6537edb6" + }, + { + "kind": "conditional-branch", + "line": 309, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", + "source": "Expires in \\(seconds)s", + "surface": "apple", + "id": "native.apple.30e1c342b3345290" + }, { "kind": "conditional-branch", "line": 32, @@ -37107,7 +37243,7 @@ }, { "kind": "ui-call", - "line": 487, + "line": 489, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Retry Send", "surface": "apple", @@ -37115,7 +37251,7 @@ }, { "kind": "ui-call", - "line": 501, + "line": 503, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Delete", "surface": "apple", @@ -37123,7 +37259,7 @@ }, { "kind": "ui-call", - "line": 535, + "line": 537, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Stop Listening", "surface": "apple", @@ -37131,7 +37267,7 @@ }, { "kind": "ui-call", - "line": 538, + "line": 540, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Listen", "surface": "apple", @@ -37139,7 +37275,7 @@ }, { "kind": "ui-call", - "line": 616, + "line": 618, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest", "surface": "apple", @@ -37147,7 +37283,7 @@ }, { "kind": "ui-modifier", - "line": 627, + "line": 629, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest reply", "surface": "apple", @@ -37155,7 +37291,7 @@ }, { "kind": "ui-named-argument", - "line": 647, + "line": 649, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Refresh", "surface": "apple", @@ -37163,7 +37299,7 @@ }, { "kind": "ui-call", - "line": 1033, + "line": 1036, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Copy Message", "surface": "apple", @@ -37171,7 +37307,7 @@ }, { "kind": "ui-call", - "line": 1056, + "line": 1059, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Open Full Message", "surface": "apple", @@ -37179,7 +37315,7 @@ }, { "kind": "ui-localized-call", - "line": 1077, + "line": 1080, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Reply", "surface": "apple", @@ -37187,7 +37323,7 @@ }, { "kind": "ui-localized-call", - "line": 1087, + "line": 1090, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "You", "surface": "apple", @@ -37195,7 +37331,7 @@ }, { "kind": "ui-localized-call", - "line": 1089, + "line": 1092, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Assistant", "surface": "apple", @@ -37203,7 +37339,7 @@ }, { "kind": "ui-call", - "line": 1155, + "line": 1158, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Loading chat", "surface": "apple", @@ -37211,7 +37347,7 @@ }, { "kind": "ui-modifier", - "line": 1235, + "line": 1238, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Dismiss", "surface": "apple", @@ -37299,7 +37435,7 @@ }, { "kind": "ui-localized-call", - "line": 1140, + "line": 1150, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift", "source": "Remove attachments or wait for delivery to resolve before switching chats.", "surface": "apple", @@ -37307,7 +37443,7 @@ }, { "kind": "ui-localized-call", - "line": 1198, + "line": 1208, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift", "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", "surface": "apple", diff --git a/apps/.i18n/native/ar.json b/apps/.i18n/native/ar.json index db6b67d130fc..2698e247d00c 100644 --- a/apps/.i18n/native/ar.json +++ b/apps/.i18n/native/ar.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "مرفق غير مدعوم" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "إجابة أخرى" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "إرسال" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "تمت الإجابة" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "تمت الإجابة في مكان آخر" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "انتهت الصلاحية" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "تم الإلغاء" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "قيد الانتظار" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "جارٍ الإرسال…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "تنتهي الصلاحية خلال ${seconds / 60}د ${seconds % 60}ث" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "تنتهي الصلاحية خلال ${seconds}ث" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "توسيع الخطة" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "إجابة أخرى" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "غير محدد" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "محدد" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "إرسال" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "جارٍ الإرسال…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "تنتهي الصلاحية خلال \\(seconds / 60)د \\(seconds % 60)ث" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "تنتهي الصلاحية خلال \\(seconds)ث" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/de.json b/apps/.i18n/native/de.json index e7e317b4e3ce..5f93ae8c189c 100644 --- a/apps/.i18n/native/de.json +++ b/apps/.i18n/native/de.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Nicht unterstützter Anhang" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Andere Antwort" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Absenden" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Beantwortet" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Anderswo beantwortet" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Abgelaufen" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Abgebrochen" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Ausstehend" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Wird gesendet…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Läuft in ${seconds / 60}m ${seconds % 60}s ab" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Läuft in ${seconds}s ab" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Plan ausklappen" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Andere Antwort" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Nicht ausgewählt" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Ausgewählt" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Absenden" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Wird gesendet…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Läuft in \\(seconds / 60)m \\(seconds % 60)s ab" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Läuft in \\(seconds)s ab" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/es.json b/apps/.i18n/native/es.json index 6a3cbc1f66e4..3fc69f9e3980 100644 --- a/apps/.i18n/native/es.json +++ b/apps/.i18n/native/es.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Adjunto no compatible" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Otra respuesta" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Enviar" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Respondida" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Respondida en otro lugar" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Caducada" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Cancelada" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Pendiente" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Enviando…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Caduca en ${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Caduca en ${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Expandir el plan" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Otra respuesta" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "No seleccionada" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Seleccionada" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Enviar" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Enviando…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Caduca en \\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Caduca en \\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/fa.json b/apps/.i18n/native/fa.json index 353ed7be250d..0d73f0b9ab27 100644 --- a/apps/.i18n/native/fa.json +++ b/apps/.i18n/native/fa.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "پیوست پشتیبانی‌نشده" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "پاسخ دیگر" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "ارسال" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "پاسخ داده شده" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "در جای دیگری پاسخ داده شده" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "منقضی شده" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "لغو شده" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "در انتظار" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "در حال ارسال…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "انقضا تا ${seconds / 60}m ${seconds % 60}s دیگر" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "انقضا تا ${seconds}s دیگر" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "بازکردن برنامه" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "پاسخ دیگر" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "انتخاب نشده" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "انتخاب شده" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "ارسال" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "در حال ارسال…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "انقضا تا \\(seconds / 60)m \\(seconds % 60)s دیگر" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "انقضا تا \\(seconds)s دیگر" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/fr.json b/apps/.i18n/native/fr.json index ccf74f7aff9c..578fd210a50f 100644 --- a/apps/.i18n/native/fr.json +++ b/apps/.i18n/native/fr.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Pièce jointe non prise en charge" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Autre réponse" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Envoyer" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Répondu" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Répondu ailleurs" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Expiré" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Annulé" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "En attente" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Envoi…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Expire dans ${seconds / 60} min ${seconds % 60} s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Expire dans ${seconds} s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Développer le plan" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Autre réponse" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Non sélectionné" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Sélectionné" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Envoyer" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Envoi…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Expire dans \\(seconds / 60) min \\(seconds % 60) s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Expire dans \\(seconds) s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/hi.json b/apps/.i18n/native/hi.json index 108d4a30d41c..9d0e2e93564b 100644 --- a/apps/.i18n/native/hi.json +++ b/apps/.i18n/native/hi.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "असमर्थित अटैचमेंट" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "अन्य उत्तर" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "सबमिट करें" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "उत्तर दिया गया" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "कहीं और उत्तर दिया गया" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "समय समाप्त" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "रद्द किया गया" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "लंबित" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "सबमिट किया जा रहा है…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "${seconds / 60}m ${seconds % 60}s में समाप्त होगा" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "${seconds}s में समाप्त होगा" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "योजना विस्तृत करें" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "अन्य उत्तर" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "चयनित नहीं" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "चयनित" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "सबमिट करें" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "सबमिट किया जा रहा है…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "\\(seconds / 60)m \\(seconds % 60)s में समाप्त होगा" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "\\(seconds)s में समाप्त होगा" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/id.json b/apps/.i18n/native/id.json index e69d686b806a..6d8c5fae09db 100644 --- a/apps/.i18n/native/id.json +++ b/apps/.i18n/native/id.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Lampiran tidak didukung" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Jawaban lain" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Kirim" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Sudah dijawab" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Dijawab di tempat lain" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Kedaluwarsa" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Dibatalkan" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Menunggu" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Mengirim…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Kedaluwarsa dalam ${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Kedaluwarsa dalam ${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Luaskan rencana" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Jawaban lain" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Tidak dipilih" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Dipilih" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Kirim" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Mengirim…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Kedaluwarsa dalam \\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Kedaluwarsa dalam \\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/it.json b/apps/.i18n/native/it.json index 6c81fc4a3848..395dff367e6e 100644 --- a/apps/.i18n/native/it.json +++ b/apps/.i18n/native/it.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Allegato non supportato" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Altra risposta" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Invia" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Risposto" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Risposto altrove" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Scaduto" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Annullato" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "In attesa" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Invio in corso…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Scade tra ${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Scade tra ${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Espandi il piano" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Altra risposta" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Non selezionato" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Selezionato" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Invia" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Invio in corso…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Scade tra \\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Scade tra \\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/ja-JP.json b/apps/.i18n/native/ja-JP.json index 396677074b89..0f711b64b8c0 100644 --- a/apps/.i18n/native/ja-JP.json +++ b/apps/.i18n/native/ja-JP.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "サポートされていない添付ファイル" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "その他の回答" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "送信" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "回答済み" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "他で回答済み" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "期限切れ" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "キャンセル済み" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "保留中" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "送信中…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "期限切れまで${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "期限切れまで${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "計画を展開" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "その他の回答" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "未選択" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "選択済み" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "送信" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "送信中…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "期限切れまで\\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "期限切れまで\\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/ko.json b/apps/.i18n/native/ko.json index 7b26781a2b3d..e3aea0e9edbe 100644 --- a/apps/.i18n/native/ko.json +++ b/apps/.i18n/native/ko.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "지원되지 않는 첨부 파일" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "기타 답변" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "제출" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "응답 완료" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "다른 곳에서 응답 완료" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "만료됨" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "취소됨" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "대기 중" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "제출 중…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "${seconds / 60}분 ${seconds % 60}초 후 만료" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "${seconds}초 후 만료" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "계획 펼치기" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "기타 답변" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "선택되지 않음" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "선택됨" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "제출" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "제출 중…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "\\(seconds / 60)분 \\(seconds % 60)초 후 만료" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "\\(seconds)초 후 만료" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/nl.json b/apps/.i18n/native/nl.json index 1f634bfa7145..ebeacd69de11 100644 --- a/apps/.i18n/native/nl.json +++ b/apps/.i18n/native/nl.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Niet-ondersteunde bijlage" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Ander antwoord" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Indienen" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Beantwoord" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Elders beantwoord" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Verlopen" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Geannuleerd" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "In afwachting" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Bezig met indienen…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Verloopt over ${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Verloopt over ${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Plan uitklappen" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Ander antwoord" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Niet geselecteerd" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Geselecteerd" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Indienen" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Bezig met indienen…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Verloopt over \\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Verloopt over \\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/pl.json b/apps/.i18n/native/pl.json index a9d148a790ae..f8c52040a94d 100644 --- a/apps/.i18n/native/pl.json +++ b/apps/.i18n/native/pl.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Nieobsługiwany załącznik" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Inna odpowiedź" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Prześlij" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Udzielono odpowiedzi" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Odpowiedziano gdzie indziej" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Wygasło" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Anulowano" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Oczekuje" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Przesyłanie…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Wygasa za ${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Wygasa za ${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Rozwiń plan" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Inna odpowiedź" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Nie wybrano" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Wybrano" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Prześlij" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Przesyłanie…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Wygasa za \\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Wygasa za \\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/pt-BR.json b/apps/.i18n/native/pt-BR.json index e463f1353951..19157fc44a0a 100644 --- a/apps/.i18n/native/pt-BR.json +++ b/apps/.i18n/native/pt-BR.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Anexo não compatível" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Outra resposta" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Enviar" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Respondido" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Respondido em outro lugar" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Expirado" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Cancelado" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Pendente" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Enviando…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Expira em ${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Expira em ${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Expandir plano" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Outra resposta" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Não selecionado" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Selecionado" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Enviar" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Enviando…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Expira em \\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Expira em \\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/ru.json b/apps/.i18n/native/ru.json index c909c8fababb..b85d5701a5cf 100644 --- a/apps/.i18n/native/ru.json +++ b/apps/.i18n/native/ru.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Неподдерживаемое вложение" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Другой ответ" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Отправить" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Отвечено" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Отвечено в другом месте" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Срок истёк" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Отменено" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Ожидает ответа" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Отправка…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Истекает через ${seconds / 60} мин ${seconds % 60} с" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Истекает через ${seconds} с" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Развернуть план" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Другой ответ" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Не выбрано" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Выбрано" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Отправить" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Отправка…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Истекает через \\(seconds / 60) мин \\(seconds % 60) с" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Истекает через \\(seconds) с" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/sv.json b/apps/.i18n/native/sv.json index 82a727152eeb..82875ddfbac2 100644 --- a/apps/.i18n/native/sv.json +++ b/apps/.i18n/native/sv.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Bilaga stöds inte" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Annat svar" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Skicka" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Besvarad" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Besvarad på annat håll" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Utgången" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Avbruten" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Väntande" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Skickar…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Upphör om ${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Upphör om ${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Visa plan" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Annat svar" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Inte valt" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Valt" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Skicka" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Skickar…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Upphör om \\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Upphör om \\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/th.json b/apps/.i18n/native/th.json index 4524f4e9403e..5bf285935c34 100644 --- a/apps/.i18n/native/th.json +++ b/apps/.i18n/native/th.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "ไฟล์แนบที่ไม่รองรับ" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "คำตอบอื่น" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "ส่ง" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "ตอบแล้ว" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "ตอบจากที่อื่นแล้ว" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "หมดอายุแล้ว" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "ยกเลิกแล้ว" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "รอดำเนินการ" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "กำลังส่ง…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "หมดอายุใน ${seconds / 60} นาที ${seconds % 60} วินาที" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "หมดอายุใน ${seconds} วินาที" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "ขยายแผน" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "คำตอบอื่น" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "ไม่ได้เลือก" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "เลือกแล้ว" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "ส่ง" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "กำลังส่ง…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "หมดอายุใน \\(seconds / 60) นาที \\(seconds % 60) วินาที" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "หมดอายุใน \\(seconds) วินาที" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/tr.json b/apps/.i18n/native/tr.json index bc7369804786..a34847d48844 100644 --- a/apps/.i18n/native/tr.json +++ b/apps/.i18n/native/tr.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Desteklenmeyen ek" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Diğer yanıt" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Gönder" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Yanıtlandı" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Başka yerde yanıtlandı" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Süresi doldu" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "İptal edildi" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Bekliyor" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Gönderiliyor…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Sona ermesine ${seconds / 60} dk ${seconds % 60} sn" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Sona ermesine ${seconds} sn" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Planı genişlet" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Diğer yanıt" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Seçilmedi" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Seçildi" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Gönder" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Gönderiliyor…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Sona ermesine \\(seconds / 60) dk \\(seconds % 60) sn" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Sona ermesine \\(seconds) sn" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/uk.json b/apps/.i18n/native/uk.json index cb17406fa433..bbdbab70e4d0 100644 --- a/apps/.i18n/native/uk.json +++ b/apps/.i18n/native/uk.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Непідтримуване вкладення" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Інша відповідь" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Надіслати" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Відповідь надано" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Відповідь надано в іншому місці" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Термін дії минув" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Скасовано" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Очікує" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Надсилання…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Залишилося ${seconds / 60} хв ${seconds % 60} с" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Залишилося ${seconds} с" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Розгорнути план" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Інша відповідь" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Не вибрано" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Вибрано" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Надіслати" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Надсилання…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Залишилося \\(seconds / 60) хв \\(seconds % 60) с" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Залишилося \\(seconds) с" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/vi.json b/apps/.i18n/native/vi.json index 75ba1a1fe08a..1e4200348a28 100644 --- a/apps/.i18n/native/vi.json +++ b/apps/.i18n/native/vi.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "Tệp đính kèm không được hỗ trợ" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "Câu trả lời khác" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "Gửi" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "Đã trả lời" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "Đã trả lời ở nơi khác" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "Đã hết hạn" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "Đã hủy" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "Đang chờ" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "Đang gửi…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "Hết hạn sau ${seconds / 60}m ${seconds % 60}s" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "Hết hạn sau ${seconds}s" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "Mở rộng kế hoạch" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "Câu trả lời khác" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "Chưa chọn" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "Đã chọn" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "Gửi" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "Đang gửi…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "Hết hạn sau \\(seconds / 60)m \\(seconds % 60)s" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "Hết hạn sau \\(seconds)s" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/zh-CN.json b/apps/.i18n/native/zh-CN.json index 1847f77b58ac..59c85e463260 100644 --- a/apps/.i18n/native/zh-CN.json +++ b/apps/.i18n/native/zh-CN.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "不支持的附件" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "其他答案" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "提交" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "已回答" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "已在其他地方回答" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "已过期" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "已取消" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "待处理" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "正在提交…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "${seconds / 60}分${seconds % 60}秒后过期" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "${seconds}秒后过期" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "展开计划" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "其他答案" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "未选择" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "已选择" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "提交" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "正在提交…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "\\(seconds / 60)分\\(seconds % 60)秒后过期" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "\\(seconds)秒后过期" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/.i18n/native/zh-TW.json b/apps/.i18n/native/zh-TW.json index 63ce785b75a3..5456d8f418ed 100644 --- a/apps/.i18n/native/zh-TW.json +++ b/apps/.i18n/native/zh-TW.json @@ -7343,6 +7343,56 @@ "source": "Unsupported attachment", "translated": "不支援的附件" }, + { + "id": "native.android.bf40f9f9a508155d", + "source": "Other answer", + "translated": "其他答案" + }, + { + "id": "native.android.fa81739f52e7697f", + "source": "Submit", + "translated": "提交" + }, + { + "id": "native.android.f68cd7a84be4089e", + "source": "Answered", + "translated": "已回答" + }, + { + "id": "native.android.a0dc883947c2f980", + "source": "Answered elsewhere", + "translated": "已在其他地方回答" + }, + { + "id": "native.android.1a0e6b33059a63aa", + "source": "Expired", + "translated": "已過期" + }, + { + "id": "native.android.d6d8fc83cec68418", + "source": "Cancelled", + "translated": "已取消" + }, + { + "id": "native.android.73f64d0d54b7f9c0", + "source": "Pending", + "translated": "待處理" + }, + { + "id": "native.android.92dfa7979d04989c", + "source": "Submitting…", + "translated": "正在提交…" + }, + { + "id": "native.android.c5e9ee99f9d9c612", + "source": "Expires in ${seconds / 60}m ${seconds % 60}s", + "translated": "將於 ${seconds / 60}m ${seconds % 60}s 後過期" + }, + { + "id": "native.android.2ca602fbdf96f1e7", + "source": "Expires in ${seconds}s", + "translated": "將於 ${seconds}s 後過期" + }, { "id": "native.android.118e8d239b18895e", "source": "Model", @@ -22913,6 +22963,41 @@ "source": "Expand plan", "translated": "展開計畫" }, + { + "id": "native.apple.da4494f9e875169d", + "source": "Other answer", + "translated": "其他答案" + }, + { + "id": "native.apple.e9c3855562d8e4e9", + "source": "Not selected", + "translated": "未選取" + }, + { + "id": "native.apple.bd4335213464d4be", + "source": "Selected", + "translated": "已選取" + }, + { + "id": "native.apple.e3095b651fc46b52", + "source": "Submit", + "translated": "提交" + }, + { + "id": "native.apple.6c1adc16e0a846e5", + "source": "Submitting…", + "translated": "正在提交…" + }, + { + "id": "native.apple.698bdb0d6537edb6", + "source": "Expires in \\(seconds / 60)m \\(seconds % 60)s", + "translated": "將於 \\(seconds / 60)m \\(seconds % 60)s 後過期" + }, + { + "id": "native.apple.30e1c342b3345290", + "source": "Expires in \\(seconds)s", + "translated": "將於 \\(seconds)s 後過期" + }, { "id": "native.apple.7cd6fb01b9f5d14a", "source": "User", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 50cf40ca9a3e..c05767230155 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -7,6 +7,7 @@ import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatOutboxItem import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatPlanStep +import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.ChatThinkingLevelSelection import ai.openclaw.app.chat.ChatWidgetResource @@ -614,6 +615,7 @@ class MainViewModel private constructor( val chatModelCatalog: StateFlow> = runtimeState(initial = emptyList()) { it.chatModelCatalog } val chatStreamingAssistantText: StateFlow = runtimeState(initial = null) { it.chatStreamingAssistantText } val chatPendingToolCalls: StateFlow> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls } + val chatQuestions: StateFlow> = runtimeState(initial = emptyList()) { it.chatQuestions } val chatPlanSteps: StateFlow> = runtimeState(initial = emptyList()) { it.chatPlanSteps } val chatSessions: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessions } val pendingRunCount: StateFlow = runtimeState(initial = 0) { it.pendingRunCount } @@ -1648,6 +1650,13 @@ class MainViewModel private constructor( ensureRuntime().deleteChatOutboxCommand(id) } + fun resolveChatQuestion( + id: String, + answers: Map>, + ) { + ensureRuntime().resolveChatQuestion(id, answers) + } + suspend fun listBackgroundTasks(agentId: String): List = ensureRuntime().listBackgroundTasks(agentId) suspend fun getBackgroundTask(taskId: String): BackgroundTask = ensureRuntime().getBackgroundTask(taskId) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 0b016d53452d..530f5d75fa83 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -11,6 +11,7 @@ import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatOutboxItem import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatPlanStep +import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionDeletion import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.ChatThinkingLevelSelection @@ -2412,6 +2413,7 @@ class NodeRuntime private constructor( val chatModelCatalog: StateFlow> = chat.modelCatalog val chatStreamingAssistantText: StateFlow = chat.streamingAssistantText val chatPendingToolCalls: StateFlow> = chat.pendingToolCalls + val chatQuestions: StateFlow> = chat.questions val chatPlanSteps: StateFlow> = chat.planSteps val chatSessions: StateFlow> = chat.sessions val pendingRunCount: StateFlow = chat.pendingRunCount @@ -2426,6 +2428,11 @@ class NodeRuntime private constructor( fun deleteChatOutboxCommand(id: String) = chat.deleteOutboxCommand(id) + fun resolveChatQuestion( + id: String, + answers: Map>, + ) = chat.resolveQuestion(id, answers) + private fun applyScreenshotFixture() { check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" } _serverName.value = "OpenClaw Gateway" diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 62922c2668e1..be1e81df62e6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -5,6 +5,8 @@ import ai.openclaw.app.gateway.GatewayRequestDefinitiveFailure import ai.openclaw.app.gateway.GatewayRequestNotEnqueued import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.QuestionListResult +import ai.openclaw.app.gateway.QuestionRecord import ai.openclaw.app.gateway.parseChatSendAck import ai.openclaw.app.i18n.NativeText import ai.openclaw.app.i18n.nativeText @@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -250,6 +253,19 @@ class ChatController internal constructor( private val _pendingToolCalls = MutableStateFlow>(emptyList()) val pendingToolCalls: StateFlow> = _pendingToolCalls.asStateFlow() + private val _questions = MutableStateFlow>(emptyList()) + val questions: StateFlow> = _questions.asStateFlow() + private val questionStateLock = Any() + private var questionStateRevision = 0L + private var questionRefreshGeneration = 0L + + private data class QuestionEvictionJob( + val job: Job, + val observedAtMs: Long?, + ) + + private val questionEvictionJobs = mutableMapOf() + private val _planSteps = MutableStateFlow>(emptyList()) val planSteps: StateFlow> = _planSteps.asStateFlow() @@ -517,6 +533,7 @@ class ChatController internal constructor( } private fun refreshConnectedGateway() { + refreshQuestions() if (!restoreRunStateOnReconnect) { refresh() return @@ -546,6 +563,7 @@ class ChatController internal constructor( ) clearLiveHistoryMarker() _sessions.value = emptyList() + clearQuestions() applyThinkingMetadata(null) sessionsListArchived = false unreadPatchSessionKey = null @@ -2030,6 +2048,7 @@ class ChatController internal constructor( } } "health" -> { + refreshQuestions() if (restoreRunStateOnReconnect) { refreshHistoryForRecovery(forceHealth = true, completesReconnectRecovery = true) } else { @@ -2043,6 +2062,7 @@ class ChatController internal constructor( pendingToolCallsById.clear() publishPendingToolCalls() _streamingAssistantText.value = null + refreshQuestions() refreshHistoryForRecovery() } "chat" -> { @@ -2064,6 +2084,250 @@ class ChatController internal constructor( if (payloadJson.isNullOrBlank()) return handleAgentEvent(payloadJson) } + "question.requested" -> { + if (payloadJson.isNullOrBlank()) return + handleQuestionRequested(payloadJson) + } + "question.resolved" -> { + if (payloadJson.isNullOrBlank()) return + handleQuestionResolved(payloadJson) + } + } + } + + fun resolveQuestion( + id: String, + answers: Map>, + ) { + val gatewayId = currentCacheScope()?.gatewayId + updateQuestions { prompts -> + prompts.map { prompt -> + if (prompt.record.id == id) prompt.copy(submitting = true, errorText = null) else prompt + } + } + scope.launch { + try { + val params = + buildJsonObject { + put("id", JsonPrimitive(id)) + put( + "answers", + buildJsonObject { + put( + "answers", + buildJsonObject { + answers.forEach { (questionId, values) -> + put(questionId, buildJsonObject { put("answers", JsonArray(values.map(::JsonPrimitive))) }) + } + }, + ) + }, + ) + } + requestGatewayBound(gatewayId, "question.resolve", params.toString()) + updateQuestions { prompts -> + prompts.map { prompt -> + if (prompt.record.id == id) { + prompt.copy( + record = prompt.record.copy(status = "answered"), + submitting = false, + answeredLocally = true, + terminalObservedAtMs = prompt.terminalObservedAtMs ?: System.currentTimeMillis(), + ) + } else { + prompt + } + } + } + } catch (error: Throwable) { + updateQuestions { prompts -> + prompts.map { prompt -> + if (prompt.record.id == id) prompt.copy(submitting = false, errorText = error.message ?: "Question failed") else prompt + } + } + } + } + } + + private fun refreshQuestions() { + val gatewayScope = currentCacheScope() + val (refreshGeneration, stateRevision) = + synchronized(questionStateLock) { + questionRefreshGeneration += 1 + questionRefreshGeneration to questionStateRevision + } + scope.launch { + try { + val response = requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}") + if (gatewayScope != currentCacheScope()) return@launch + val records = json.decodeFromString(response).questions + synchronized(questionStateLock) { + if (refreshGeneration != questionRefreshGeneration || stateRevision != questionStateRevision) { + return@synchronized + } + val current = _questions.value + val existing = current.associateBy { it.record.id } + val nowMs = System.currentTimeMillis() + val listedIds = records.mapTo(mutableSetOf()) { it.id } + val retainedTerminal = current.filter { it.record.id !in listedIds && it.shouldRetainAfterList(nowMs) } + val next = + records.map { record -> + existing[record.id]?.let { prompt -> + prompt.copy( + record = record, + submitting = prompt.submitting && record.status == "pending", + terminalObservedAtMs = + if (record.status == "pending" && nowMs < record.expiresAtMs) { + null + } else { + prompt.terminalObservedAtMs ?: nowMs + }, + ) + } ?: ChatQuestionPrompt( + record = record, + terminalObservedAtMs = nowMs.takeIf { record.status != "pending" }, + ) + } + retainedTerminal + if (next != current) { + _questions.value = next + questionStateRevision += 1 + } + syncQuestionEvictionsLocked() + } + } catch (_: Throwable) { + // Older gateways and tokens without operator.questions keep ordinary chat usable. + } + } + } + + private fun handleQuestionRequested(payloadJson: String) { + val record = runCatching { json.decodeFromString(payloadJson) }.getOrNull() ?: return + updateQuestions { prompts -> + if (prompts.any { it.record.id == record.id }) { + prompts.map { prompt -> + if (prompt.record.id == record.id) { + prompt.copy( + record = record, + submitting = prompt.submitting && record.status == "pending", + // A replayed pending event must drop stale terminal retention or + // the zero-delay eviction loop respins without ever removing it. + terminalObservedAtMs = + if (record.status == "pending") null else prompt.terminalObservedAtMs, + ) + } else { + prompt + } + } + } else { + prompts + ChatQuestionPrompt(record) + } + } + refreshQuestions() + } + + private fun handleQuestionResolved(payloadJson: String) { + val payload = runCatching { json.parseToJsonElement(payloadJson).jsonObject }.getOrNull() ?: return + val id = payload["id"].asStringOrNull() ?: return + val status = payload["status"].asStringOrNull() ?: return + val nowMs = System.currentTimeMillis() + updateQuestions { prompts -> + prompts.map { prompt -> + if (prompt.record.id == id) { + prompt.copy( + record = prompt.record.copy(status = status), + submitting = false, + terminalObservedAtMs = prompt.terminalObservedAtMs ?: nowMs, + ) + } else { + prompt + } + } + } + refreshQuestions() + } + + private fun updateQuestions(transform: (List) -> List) { + synchronized(questionStateLock) { + val current = _questions.value + val next = transform(current) + if (next == current) return + _questions.value = next + questionStateRevision += 1 + syncQuestionEvictionsLocked() + } + } + + private fun syncQuestionEvictionsLocked(nowMs: Long = System.currentTimeMillis()) { + val currentById = _questions.value.associateBy { it.record.id } + questionEvictionJobs.entries.removeAll { (id, scheduled) -> + val prompt = currentById[id] + if (prompt == null || scheduled.observedAtMs != prompt.terminalObservedAtMs) { + scheduled.job.cancel() + true + } else { + false + } + } + for (prompt in _questions.value) { + if (questionEvictionJobs.containsKey(prompt.record.id)) continue + val id = prompt.record.id + val observedAt = prompt.terminalObservedAtMs + val remainingMs = + if (observedAt == null && prompt.record.status == "pending" && prompt.record.expiresAtMs != Long.MAX_VALUE) { + (prompt.record.expiresAtMs - nowMs).coerceAtLeast(0) + } else if (observedAt != null) { + (QUESTION_TERMINAL_RETENTION_MS - (nowMs - observedAt)).coerceAtLeast(0) + } else { + continue + } + val job = + scope.launch(start = CoroutineStart.LAZY) { + delay(remainingMs) + synchronized(questionStateLock) { + questionEvictionJobs.remove(id) + val current = _questions.value + if (observedAt == null) { + val next = + current.map { + if (it.record.id == id && it.record.status == "pending" && it.terminalObservedAtMs == null) { + it.copy(terminalObservedAtMs = System.currentTimeMillis()) + } else { + it + } + } + if (next != current) { + _questions.value = next + questionStateRevision += 1 + } + syncQuestionEvictionsLocked() + return@synchronized + } + val next = + current.filterNot { + it.record.id == id && + it.terminalObservedAtMs == observedAt && + (it.status() == ChatQuestionStatus.Expired || it.record.status != "pending") + } + if (next != current) { + _questions.value = next + questionStateRevision += 1 + } else { + syncQuestionEvictionsLocked() + } + } + } + questionEvictionJobs[id] = QuestionEvictionJob(job, observedAt) + job.start() + } + } + + private fun clearQuestions() { + synchronized(questionStateLock) { + questionRefreshGeneration += 1 + if (_questions.value.isEmpty()) return + _questions.value = emptyList() + questionStateRevision += 1 + syncQuestionEvictionsLocked() } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt new file mode 100644 index 000000000000..68e96ba04456 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt @@ -0,0 +1,108 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.Question +import ai.openclaw.app.gateway.QuestionRecord + +internal const val QUESTION_TERMINAL_RETENTION_MS = 15_000L + +enum class ChatQuestionStatus { + Pending, + Submitting, + Answered, + AnsweredElsewhere, + Expired, + Cancelled, +} + +data class ChatQuestionPrompt( + val record: QuestionRecord, + val submitting: Boolean = false, + val answeredLocally: Boolean = false, + val errorText: String? = null, + val terminalObservedAtMs: Long? = null, +) { + fun status(nowMs: Long = System.currentTimeMillis()): ChatQuestionStatus = + when (record.status) { + "answered" -> if (answeredLocally) ChatQuestionStatus.Answered else ChatQuestionStatus.AnsweredElsewhere + "cancelled" -> ChatQuestionStatus.Cancelled + "expired" -> ChatQuestionStatus.Expired + else -> + when { + nowMs >= record.expiresAtMs -> ChatQuestionStatus.Expired + submitting -> ChatQuestionStatus.Submitting + else -> ChatQuestionStatus.Pending + } + } + + fun shouldRetainAfterList(nowMs: Long): Boolean = terminalObservedAtMs?.let { nowMs - it < QUESTION_TERMINAL_RETENTION_MS } == true +} + +data class ChatQuestionDraft( + val selectedOptions: Map> = emptyMap(), + val otherText: Map = emptyMap(), +) { + fun toggle( + question: Question, + label: String, + ): ChatQuestionDraft { + if (question.options.none { it.label == label }) return this + val selected = selectedOptions[question.id].orEmpty() + val next = + if (question.multiSelect == true) { + if (label in selected) selected - label else selected + label + } else if (selected == setOf(label)) { + emptySet() + } else { + setOf(label) + } + return copy( + selectedOptions = selectedOptions + (question.id to next), + otherText = if (question.multiSelect != true && next.isNotEmpty()) otherText + (question.id to "") else otherText, + ) + } + + fun setOther( + question: Question, + value: String, + ): ChatQuestionDraft { + if (question.options.isNotEmpty() && question.isOther != true) return this + val clearOptions = question.multiSelect != true && value.isNotBlank() + return copy( + selectedOptions = if (clearOptions) selectedOptions + (question.id to emptySet()) else selectedOptions, + otherText = otherText + (question.id to value), + ) + } + + fun answers(questions: List): Map>? { + val result = linkedMapOf>() + for (question in questions) { + val selected = selectedOptions[question.id].orEmpty() + val values = question.options.mapNotNull { option -> option.label.takeIf { it in selected } }.toMutableList() + otherText[question.id]?.trim()?.takeIf { it.isNotEmpty() }?.let(values::add) + if (values.isEmpty()) return null + result[question.id] = values + } + return result + } +} + +internal fun questionsForSession( + prompts: List, + sessionKey: String, + mainSessionKey: String, + activeAgentId: String, +): List { + val main = mainSessionKey.trim().ifEmpty { "main" } + val current = sessionKey.trim().let { if (it == "main") main else it } + val activeAgent = activeAgentId.trim().lowercase() + return prompts.filter { prompt -> + val key = prompt.record.sessionKey?.trim() ?: return@filter true + val sessionMatches = key == sessionKey || key == current || (key == "main" && current == main) + val promptAgent = + prompt.record.agentId + ?.trim() + .orEmpty() + .lowercase() + sessionMatches && (promptAgent.isEmpty() || activeAgent.isEmpty() || promptAgent == activeAgent) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 7c4997f45161..e5a43d3a88e7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -73,6 +73,46 @@ data class GatewayNodeInvokeRequest( val idempotencyKey: String? = null, ) +@Serializable +data class QuestionOption( + val label: String, + val description: String? = null, +) + +@Serializable +data class Question( + val id: String, + val header: String, + val question: String, + val options: List, + val multiSelect: Boolean? = null, + val isOther: Boolean? = null, + val isSecret: Boolean? = null, +) + +@Serializable +data class QuestionAnswers( + val answers: Map, +) + +@Serializable +data class QuestionRecord( + val id: String, + val questions: List, + val agentId: String? = null, + val sessionKey: String? = null, + val createdAtMs: Long, + val expiresAtMs: Long, + val status: String, + val answers: QuestionAnswers? = null, + val resolvedBy: String? = null, +) + +@Serializable +data class QuestionListResult( + val questions: List, +) + @Serializable data class GatewayEventFrameStateVersion( val presence: Long, @@ -85,6 +125,11 @@ data class GatewayNodeInvokeResultParamsError( val message: String? = null, ) +@Serializable +data class QuestionAnswersAnswersValue( + val answers: List, +) + enum class GatewayMethod( val rawValue: String, ) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 1d3ca35c7c74..50331bf9af8c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -1082,6 +1082,7 @@ class GatewaySession( setOf( "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt index a56e5b6745d1..c020daf726c3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt @@ -152,6 +152,8 @@ internal val nativeStringResourceIds: Map = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below." to R.string.native_58268edca12a81a1, "Android setup guide" to R.string.native_14638daf580b6ee0, "Another cron action is still finishing." to R.string.native_4072c873764625e9, + "Answered" to R.string.native_665590354e719bcf, + "Answered elsewhere" to R.string.native_071c496aa34a4fd5, "App Filter" to R.string.native_eaf3a81a6411115f, "App language" to R.string.native_b8352b44a588721c, "App list stays on this phone." to R.string.native_aab53a916682c720, @@ -229,6 +231,7 @@ internal val nativeStringResourceIds: Map = "Camera enabled" to R.string.native_29d5c526bab8bf50, "Cancel" to R.string.cancel, "Cancel voice note" to R.string.native_fae8fa3bc050ab7e, + "Cancelled" to R.string.native_d353a99eb4556847, "Cannot reach gateway" to R.string.native_d8f247890b029a85, "Canvas" to R.string.native_3824a9f4dafe92c6, "Canvas Status" to R.string.native_584162ac73e99ee6, @@ -480,7 +483,10 @@ internal val nativeStringResourceIds: Map = "Exec approval requests will appear here while this phone is connected." to R.string.native_e1db8571dfe6dfcf, "Expand link preview" to R.string.native_c184b2ada7abc688, "Expand plan checklist" to R.string.native_d30f33731adb6b40, + "Expired" to R.string.native_424a2551d356754c, "Expires \${duration}" to R.string.native_48377001408f13be, + "Expires in \${seconds / 60}m \${seconds % 60}s" to R.string.native_9dca3c2b01b40c0b, + "Expires in \${seconds}s" to R.string.native_e9e3a4b23345cb57, "Expression" to R.string.native_c67415bcff328a59, "Extra" to R.string.native_2e4aaf05df1cbfb3, "Failed" to R.string.native_031a8f0f659df890, @@ -864,6 +870,7 @@ internal val nativeStringResourceIds: Map = "Optional description" to R.string.native_c7b7b7f01360f6a5, "Optional override" to R.string.native_217119a61e20f1f2, "Optional path" to R.string.native_1ed1c61b19068fa4, + "Other answer" to R.string.native_773e7729b237d4ea, "Output" to R.string.native_b2439bcb8dee14b6, "Overview" to R.string.native_d4b1ea5708dd5329, "PDF" to R.string.native_1d393b0081b632c5, @@ -1171,6 +1178,8 @@ internal val nativeStringResourceIds: Map = "Stop Dictation" to R.string.native_bf399ffa03617a96, "Sub-agent" to R.string.native_429f6057535c1ea3, "Subagents" to R.string.native_88296ab3d6665a8e, + "Submit" to R.string.native_155f816c0407310c, + "Submitting…" to R.string.native_49195f559e4a9f5b, "Subsystem" to R.string.native_63734ae10b7e46e9, "Suggest Task" to R.string.native_49bb137d12031cbc, "Summarize recent sessions and next steps." to R.string.native_11e47358a692d81b, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 95997fde5f6b..3c721fa27ace 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -44,6 +44,7 @@ class ConnectionManager( // sessions.patch (model switching); stored tokens keep their granted scopes. "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt new file mode 100644 index 000000000000..658246a12b1b --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt @@ -0,0 +1,196 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatQuestionDraft +import ai.openclaw.app.chat.ChatQuestionPrompt +import ai.openclaw.app.chat.ChatQuestionStatus +import ai.openclaw.app.gateway.Question +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.ui.design.ClawTheme +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +@Composable +internal fun ChatQuestionCard( + prompt: ChatQuestionPrompt, + onSubmit: (String, Map>) -> Unit, + modifier: Modifier = Modifier, +) { + var draft by remember(prompt.record.id) { mutableStateOf(ChatQuestionDraft()) } + var nowMs by remember(prompt.record.id) { mutableLongStateOf(System.currentTimeMillis()) } + val status = prompt.status(nowMs) + val pending = status == ChatQuestionStatus.Pending + LaunchedEffect(prompt.record.id, prompt.record.expiresAtMs, status) { + while (status == ChatQuestionStatus.Pending || status == ChatQuestionStatus.Submitting) { + delay(1000) + nowMs = System.currentTimeMillis() + } + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(ClawTheme.radii.sheet), + color = ClawTheme.colors.surfaceRaised, + border = BorderStroke(1.dp, ClawTheme.colors.border), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + prompt.record.questions.forEach { question -> + QuestionSection( + question = question, + draft = draft, + enabled = pending, + onDraftChanged = { draft = it }, + ) + } + QuestionFooter( + prompt = prompt, + draft = draft, + status = status, + nowMs = nowMs, + onSubmit = onSubmit, + ) + } + } +} + +@Composable +private fun QuestionSection( + question: Question, + draft: ChatQuestionDraft, + enabled: Boolean, + onDraftChanged: (ChatQuestionDraft) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = question.header.uppercase(), + style = ClawTheme.type.caption, + color = ClawTheme.colors.primary, + fontWeight = FontWeight.SemiBold, + ) + Text(text = question.question, style = ClawTheme.type.body, color = ClawTheme.colors.text) + question.options.forEach { option -> + val selected = option.label in draft.selectedOptions[question.id].orEmpty() + Surface( + onClick = { onDraftChanged(draft.toggle(question, option.label)) }, + enabled = enabled, + shape = RoundedCornerShape(ClawTheme.radii.row), + color = if (selected) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.Top, + ) { + if (question.multiSelect == true) { + Checkbox(checked = selected, onCheckedChange = null, enabled = enabled) + } else { + RadioButton(selected = selected, onClick = null, enabled = enabled) + } + Spacer(Modifier.width(6.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = option.label, style = ClawTheme.type.body, color = ClawTheme.colors.text) + option.description?.takeIf { it.isNotBlank() }?.let { description -> + Text(text = description, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + } + } + } + } + } + if (question.options.isEmpty() || question.isOther == true) { + OutlinedTextField( + value = draft.otherText[question.id].orEmpty(), + onValueChange = { onDraftChanged(draft.setOther(question, it)) }, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + label = { Text(nativeString("Other answer")) }, + minLines = 1, + maxLines = 4, + ) + } + } +} + +@Composable +private fun QuestionFooter( + prompt: ChatQuestionPrompt, + draft: ChatQuestionDraft, + status: ChatQuestionStatus, + nowMs: Long, + onSubmit: (String, Map>) -> Unit, +) { + val answers = draft.answers(prompt.record.questions) + if (status == ChatQuestionStatus.Pending || status == ChatQuestionStatus.Submitting) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = questionCountdown(prompt.record.expiresAtMs, nowMs), + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + Spacer(Modifier.weight(1f)) + Button( + onClick = { answers?.let { onSubmit(prompt.record.id, it) } }, + enabled = answers != null && status == ChatQuestionStatus.Pending, + ) { + Text(if (status == ChatQuestionStatus.Submitting) nativeString("Submitting…") else nativeString("Submit")) + } + } + prompt.errorText?.let { error -> + Text(text = error, style = ClawTheme.type.caption, color = ClawTheme.colors.danger) + } + } else { + Text( + text = + when (status) { + ChatQuestionStatus.Answered -> nativeString("Answered") + ChatQuestionStatus.AnsweredElsewhere -> nativeString("Answered elsewhere") + ChatQuestionStatus.Expired -> nativeString("Expired") + ChatQuestionStatus.Cancelled -> nativeString("Cancelled") + ChatQuestionStatus.Pending -> nativeString("Pending") + ChatQuestionStatus.Submitting -> nativeString("Submitting…") + }, + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + fontWeight = FontWeight.SemiBold, + ) + } +} + +// nativeString is the non-composable resource accessor (nativeStringResource +// is the @Composable variant), so this helper is safe outside composition. +private fun questionCountdown( + expiresAtMs: Long, + nowMs: Long, +): String { + val seconds = ((expiresAtMs - nowMs).coerceAtLeast(0) + 999) / 1000 + return if (seconds >= 60) { + nativeString("Expires in \${seconds / 60}m \${seconds % 60}s", seconds / 60, seconds % 60) + } else { + nativeString("Expires in \${seconds}s", seconds) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index 4db62416e4a7..64771eee0be9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -13,6 +13,7 @@ import ai.openclaw.app.chat.ChatOutboxItem import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatPlanStep import ai.openclaw.app.chat.ChatPlanStepStatus +import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.ChatThinkingLevelOption import ai.openclaw.app.chat.ChatThinkingLevelSelection @@ -21,6 +22,7 @@ import ai.openclaw.app.chat.MessageSpeechPhase import ai.openclaw.app.chat.MessageSpeechState import ai.openclaw.app.chat.VoiceNoteRecorderState import ai.openclaw.app.chat.chatOutboxQueueFailureText +import ai.openclaw.app.chat.questionsForSession import ai.openclaw.app.chat.resolveChatComposerOwner import ai.openclaw.app.chat.resolveGatewayDefaultAgentId import ai.openclaw.app.currentAppLanguage @@ -185,6 +187,7 @@ fun ChatScreen( val thinkingLevelSelection by viewModel.chatThinkingLevelSelection.collectAsState() val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() + val questions by viewModel.chatQuestions.collectAsState() val planSteps by viewModel.chatPlanSteps.collectAsState() val sessions by viewModel.chatSessions.collectAsState() val chatCommands by viewModel.chatCommands.collectAsState() @@ -523,6 +526,7 @@ fun ChatScreen( historyLoading = historyLoading, pendingRunCount = pendingRunCount, pendingToolCalls = pendingToolCalls, + questions = questionsForSession(questions, sessionKey, mainSessionKey, activeAgentId), streamingAssistantText = streamingAssistantText, healthOk = healthOk, gatewayOffline = gatewayOffline, @@ -540,6 +544,7 @@ fun ChatScreen( ), onRetryOutbox = viewModel::retryChatOutboxCommand, onDeleteOutbox = viewModel::deleteChatOutboxCommand, + onResolveQuestion = viewModel::resolveChatQuestion, onStarterPrompt = { prompt -> inputDrafts[composerOwner] = prompt }, onReplyMessage = { value -> viewModel.setChatReplyDraft(value, composerOwner) }, speechState = messageSpeechState, @@ -963,6 +968,7 @@ private fun ChatMessageList( historyLoading: Boolean, pendingRunCount: Int, pendingToolCalls: List, + questions: List, streamingAssistantText: String?, healthOk: Boolean, gatewayOffline: Boolean, @@ -970,6 +976,7 @@ private fun ChatMessageList( recoveryOutboxItems: List, onRetryOutbox: (String) -> Unit, onDeleteOutbox: (String) -> Unit, + onResolveQuestion: (String, Map>) -> Unit, onStarterPrompt: (String) -> Unit, onReplyMessage: (String) -> Unit, speechState: MessageSpeechState?, @@ -978,7 +985,7 @@ private fun ChatMessageList( modifier: Modifier = Modifier, ) { val timeline = - remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText, outboxItems, recoveryOutboxItems) { + remember(messages, pendingRunCount, pendingToolCalls, questions, streamingAssistantText, outboxItems, recoveryOutboxItems) { buildChatTimeline( messages = messages, pendingRunCount = pendingRunCount, @@ -986,6 +993,7 @@ private fun ChatMessageList( streamingAssistantText = streamingAssistantText, outboxItems = outboxItems, recoveryOutboxItems = recoveryOutboxItems, + questions = questions, ) } val readerScroll = @@ -1041,6 +1049,8 @@ private fun ChatMessageList( ), ) is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls) + is ChatTimelineItem.QuestionPrompt -> + ChatQuestionCard(prompt = item.prompt, onSubmit = onResolveQuestion) is ChatTimelineItem.StreamingAssistant -> ChatBubble( messageId = null, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt index 2cd5f2fb5095..1dbf8c135537 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt @@ -4,6 +4,7 @@ import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatOutboxItem import ai.openclaw.app.chat.ChatOutboxStatus import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.OUTBOX_OWNER_CHANGED_ERROR import ai.openclaw.app.resolveAgentIdFromMainSessionKey @@ -34,6 +35,10 @@ internal sealed class ChatTimelineItem { val toolCalls: List, ) : ChatTimelineItem() + data class QuestionPrompt( + val prompt: ChatQuestionPrompt, + ) : ChatTimelineItem() + object Thinking : ChatTimelineItem() } @@ -53,11 +58,13 @@ internal fun buildChatTimeline( streamingAssistantText: String?, outboxItems: List = emptyList(), recoveryOutboxItems: List = emptyList(), + questions: List = emptyList(), ): ChatTimeline { val stream = streamingAssistantText?.trim()?.takeIf { it.isNotEmpty() } val items = buildList { // reverseLayout: index 0 renders bottom-most; queued commands are the newest user input. + questions.asReversed().forEach { prompt -> add(ChatTimelineItem.QuestionPrompt(prompt)) } outboxItems.asReversed().forEach { item -> add(ChatTimelineItem.OutboxCommand(item)) } recoveryOutboxItems.asReversed().forEach { item -> add(ChatTimelineItem.RecoveryOutboxCommand(item)) } if (recoveryOutboxItems.isNotEmpty()) add(ChatTimelineItem.OutboxRecoveryHeader(recoveryOutboxItems.size)) @@ -99,7 +106,14 @@ internal fun buildChatTimeline( latestUserMessageId = latestUserMessage?.id, latestUserMessageVersion = latestUserMessage?.let(::stableMessageVersion), latestContentVersion = - latestContentVersion(messages, pendingRunCount, pendingToolCalls, stream, outboxItems + recoveryOutboxItems), + latestContentVersion( + messages, + pendingRunCount, + pendingToolCalls, + stream, + outboxItems + recoveryOutboxItems, + questions, + ), ) } @@ -188,6 +202,7 @@ private fun latestContentVersion( pendingToolCalls: List, stream: String?, outboxItems: List = emptyList(), + questions: List = emptyList(), ): String { val latest = messages.lastOrNull() return buildString { @@ -232,6 +247,15 @@ private fun latestContentVersion( append(item.status) append(';') } + append(":questions=") + questions.forEach { prompt -> + append(prompt.record.id) + append(',') + append(prompt.record.status) + append(',') + append(prompt.submitting) + append(';') + } } } @@ -242,6 +266,7 @@ internal fun chatTimelineItemKey(item: ChatTimelineItem): String = is ChatTimelineItem.RecoveryOutboxCommand -> "outbox-recovery:${item.item.id}" is ChatTimelineItem.OutboxRecoveryHeader -> "outbox-recovery-header" is ChatTimelineItem.PendingTools -> "tools" + is ChatTimelineItem.QuestionPrompt -> "question:${item.prompt.record.id}" is ChatTimelineItem.StreamingAssistant -> "stream" ChatTimelineItem.Thinking -> "thinking" } diff --git a/apps/android/app/src/main/res/values-ar/strings.xml b/apps/android/app/src/main/res/values-ar/strings.xml index d04cbb39c030..3d34f816801b 100644 --- a/apps/android/app/src/main/res/values-ar/strings.xml +++ b/apps/android/app/src/main/res/values-ar/strings.xml @@ -59,6 +59,7 @@ "مصفوفة JSON للأمر argv" "تعذّرت قراءة هذه الصورة. اختر لقطة شاشة واضحة أو صورة لرمز QR من openclaw qr." "تعذّر %1$s مقترح ورشة عمل Skills." + "تمت الإجابة في مكان آخر" "سجّل Gateway الموافقة مرة واحدة." "status" "لا يتحقق OpenClaw من الموقع إلا عندما يطلب Gateway المقترن ذلك. في شاشة Android التالية، اختر %1$s للسماح بعمليات التحقق أثناء عمل التطبيق في الخلفية." @@ -129,6 +130,7 @@ "الأجهزة المقترنة" "%1$s معلّقة" "اسم الجهاز" + "إرسال" "الموقع" "مثال America/New_York" "الجلسة المستهدفة" @@ -382,6 +384,7 @@ "لا توجد عمليات أتمتة مطابقة." "يمكن للهاتف الوصول إلى Gateway" "Gateway" + "انتهت الصلاحية" "أعمال OpenClaw المجدولة من Gateway لديك." "Sub-agent" "في انتظار الموافقة على الجهاز" @@ -411,6 +414,7 @@ "مشكلة" "ابحث في بيانات السجل الوصفية. يتحقق Gateway من الثقة مرة أخرى قبل أي تنزيل." "استخدم عنوان IP خاصًا بشبكة LAN للإعداد المحلي، أو فعّل Tailscale Serve / اعرض عنوان URL للبوابة باستخدام wss:// للوصول عن بُعد." + "جارٍ الإرسال…" "الحساب %1$s" "Suggest Task" "بحث" @@ -584,6 +588,7 @@ "سيؤدي هذا إلى عزل \"%1$s\" وتحديث حالة ورشة Skills من Gateway." "تسجيل ملاحظة صوتية" "في قائمة الانتظار" + "تمت الإجابة" "السماح بأدوات الكاميرا عند طلبها." "المشكلات" "التنبيه الصوتي" @@ -702,6 +707,7 @@ "Session Settings" "صِل Gateway للبدء" "إشعار أمان" + "إجابة أخرى" "تجاهل تحذير الصورة المشتركة" "قيّم Gateway إصدارًا مختلفًا من ClawHub. راجع Skill مرة أخرى قبل التثبيت." "فتح وصول النظام" @@ -916,6 +922,7 @@ "تحقق" "قراءة موقع هذا الهاتف" "مفتاح المهارة" + "تنتهي الصلاحية خلال %1$sد %2$sث" "تم تثبيت %1$s." "بوابات Gateway" "وضع إعادة التوجيه" @@ -1213,6 +1220,7 @@ "... +%1$s أخرى" "توسيع قائمة التحقق الخاصة بالخطة" "المتصفح" + "تم الإلغاء" "screen record" "التشغيل معلّق" "ذكاء اصطناعي شخصي على أجهزتك" @@ -1335,6 +1343,7 @@ "المزوّدون والنماذج" "العقدة" "%1$s " + "تنتهي الصلاحية خلال %1$sث" "الموجّه غير متاح" "السجلات" "اتصل بـ Gateway لفحص مقترحات ورشة عمل Skills." diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 36c06ad62ca2..8c641717bf2e 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -59,6 +59,7 @@ "Befehl-argv-JSON-Array" "Dieses Bild konnte nicht gelesen werden. Wähle einen deutlichen Screenshot oder ein deutliches Bild des QR-Codes aus openclaw qr." "Der Skill-Workshop-Vorschlag konnte nicht mit der Aktion %1$s bearbeitet werden." + "Anderswo beantwortet" "Gateway hat die Genehmigung einmalig erfasst." "status" "OpenClaw überprüft den Standort nur, wenn Ihr gekoppeltes Gateway ihn anfordert. Wählen Sie auf dem nächsten Android-Bildschirm %1$s aus, um Überprüfungen zuzulassen, während die App im Hintergrund ausgeführt wird." @@ -129,6 +130,7 @@ "Gekoppelte Geräte" "%1$s ausstehend" "Gerätename" + "Absenden" "Standort" "z. B. America/New_York" "Sitzungsziel" @@ -382,6 +384,7 @@ "Keine passenden Automatisierungen." "Smartphone kann die Gateway erreichen" "Gateway" + "Abgelaufen" "Geplante OpenClaw-Arbeit von Ihrer Gateway." "Sub-agent" "Warten auf Gerätefreigabe" @@ -411,6 +414,7 @@ "Problem" "Durchsuchen Sie die Registry-Metadaten. Das Gateway überprüft die Vertrauenswürdigkeit vor jedem Download erneut." "Verwenden Sie für die lokale Einrichtung eine private LAN-IP oder aktivieren Sie Tailscale Serve bzw. stellen Sie für den Remotezugriff eine wss://-Gateway-URL bereit." + "Wird gesendet…" "Konto %1$s" "Suggest Task" "Suchen" @@ -584,6 +588,7 @@ "Dadurch wird \"%1$s\" unter Quarantäne gestellt und der Status von Skill Workshop über das Gateway aktualisiert." "Sprachnachricht aufnehmen" "In Warteschlange" + "Beantwortet" "Kameratools auf Anfrage zulassen." "Probleme" "Sprachaktivierung" @@ -702,6 +707,7 @@ "Session Settings" "Gateway verbinden, um zu starten" "Sicherheitshinweis" + "Andere Antwort" "Warnung zu geteiltem Bild schließen" "Das Gateway hat eine andere ClawHub-Version geprüft. Prüfen Sie den Skill vor der Installation erneut." "Systemzugriff öffnen" @@ -916,6 +922,7 @@ "Prüfen" "Standort dieses Telefons abrufen" "Skill-Schlüssel" + "Läuft in %1$sm %2$ss ab" "%1$s wurde installiert." "Gateways" "Weiterleitungsmodus" @@ -1213,6 +1220,7 @@ "... +%1$s weitere" "Plan-Checkliste ausklappen" "Browser" + "Abgebrochen" "screen record" "Ausführung ausstehend" "Persönliche KI auf deinen Geräten" @@ -1335,6 +1343,7 @@ "Anbieter & Modelle" "Knoten" "%1$s " + "Läuft in %1$ss ab" "Prompt nicht verfügbar" "Protokolle" "Verbinden Sie das Gateway, um Vorschläge aus Skill Workshop zu prüfen." diff --git a/apps/android/app/src/main/res/values-es/strings.xml b/apps/android/app/src/main/res/values-es/strings.xml index ab9eb2412e75..68d4c2bc87d8 100644 --- a/apps/android/app/src/main/res/values-es/strings.xml +++ b/apps/android/app/src/main/res/values-es/strings.xml @@ -59,6 +59,7 @@ "Array JSON de argv del comando" "No se pudo leer esa imagen. Selecciona una captura de pantalla clara o una imagen del código QR de openclaw qr." "No se pudo %1$s la propuesta de Skill Workshop." + "Respondida en otro lugar" "Gateway registró la aprobación una vez." "status" "OpenClaw solo comprueba la ubicación cuando el Gateway vinculado la solicita. En la siguiente pantalla de Android, elige %1$s para permitir las comprobaciones mientras la aplicación está en segundo plano." @@ -129,6 +130,7 @@ "Dispositivos emparejados" "%1$s pendientes" "Nombre del dispositivo" + "Enviar" "Ubicación" "p. ej. America/New_York" "Destino de la sesión" @@ -382,6 +384,7 @@ "No hay automatizaciones coincidentes." "El teléfono puede acceder al Gateway" "Gateway" + "Caducada" "Trabajo de OpenClaw programado desde tu gateway." "Sub-agent" "Esperando la aprobación del dispositivo" @@ -411,6 +414,7 @@ "Problema" "Busca en los metadatos del registro. El Gateway vuelve a verificar la confianza antes de cualquier descarga." "Use una dirección IP de LAN privada para la configuración local, o habilite Tailscale Serve / exponga una URL de gateway wss:// para el acceso remoto." + "Enviando…" "Cuenta %1$s" "Suggest Task" "Buscar" @@ -584,6 +588,7 @@ "Esto pondrá \"%1$s\" en cuarentena y actualizará el estado de Skill Workshop desde el Gateway." "Grabar nota de voz" "En cola" + "Respondida" "Permitir las herramientas de cámara cuando se soliciten." "Problemas" "Activación por voz" @@ -702,6 +707,7 @@ "Session Settings" "Conecta el Gateway para comenzar" "Aviso de seguridad" + "Otra respuesta" "Descartar advertencia de imagen compartida" "El Gateway evaluó una versión diferente de ClawHub. Revisa de nuevo la Skill antes de instalarla." "Abrir acceso del sistema" @@ -916,6 +922,7 @@ "Comprobar" "Leer la ubicación de este teléfono" "Clave de Skill" + "Caduca en %1$sm %2$ss" "Se instaló %1$s." "Gateways" "Modo de reenvío" @@ -1213,6 +1220,7 @@ "... +%1$s más" "Expandir la lista de verificación del plan" "Navegador" + "Cancelada" "screen record" "Ejecución pendiente" "IA personal en tus dispositivos" @@ -1335,6 +1343,7 @@ "Proveedores y modelos" "Nodo" "%1$s " + "Caduca en %1$ss" "Indicación no disponible" "Registros" "Conecta el Gateway para consultar las propuestas de Skill Workshop." diff --git a/apps/android/app/src/main/res/values-fa/strings.xml b/apps/android/app/src/main/res/values-fa/strings.xml index dc8902f52917..8fd1b4c84ea6 100644 --- a/apps/android/app/src/main/res/values-fa/strings.xml +++ b/apps/android/app/src/main/res/values-fa/strings.xml @@ -59,6 +59,7 @@ "آرایه JSON دستور argv" "خواندن این تصویر ممکن نبود. یک اسکرین‌شات یا تصویر واضح از کد QR ایجادشده با openclaw qr انتخاب کنید." "پیشنهاد Skill Workshop را نمی‌توان %1$s کرد." + "در جای دیگری پاسخ داده شده" "Gateway تأیید را یک‌بار ثبت کرد." "status" "OpenClaw فقط زمانی موقعیت مکانی را بررسی می‌کند که Gateway جفت‌شده شما آن را درخواست کند. در صفحه بعدی Android، %1$s را انتخاب کنید تا بررسی‌ها هنگام اجرای برنامه در پس‌زمینه مجاز باشند." @@ -129,6 +130,7 @@ "دستگاه‌های جفت‌شده" "%1$s در انتظار" "نام دستگاه" + "ارسال" "موقعیت مکانی" "مثلاً America/New_York" "مقصد نشست" @@ -382,6 +384,7 @@ "خودکارسازی منطبقی یافت نشد." "تلفن می‌تواند به Gateway دسترسی داشته باشد" "Gateway" + "منقضی شده" "کارهای زمان‌بندی‌شده OpenClaw از Gateway شما." "Sub-agent" "در انتظار تأیید دستگاه" @@ -411,6 +414,7 @@ "مشکل" "فراداده رجیستری را جستجو کنید. Gateway پیش از هرگونه دانلود، اعتماد را دوباره تأیید می‌کند." "برای راه‌اندازی محلی از یک IP خصوصی LAN استفاده کنید، یا برای دسترسی از راه دور Tailscale Serve را فعال کنید / یک نشانی URL از نوع wss:// برای Gateway در دسترس قرار دهید." + "در حال ارسال…" "حساب %1$s" "Suggest Task" "جستجو" @@ -584,6 +588,7 @@ "این کار \"%1$s\" را قرنطینه می‌کند و وضعیت Skill Workshop را از Gateway تازه‌سازی می‌کند." "ضبط یادداشت صوتی" "در صف" + "پاسخ داده شده" "در صورت درخواست، ابزارهای دوربین را مجاز کنید." "مشکلات" "بیدارباش صوتی" @@ -702,6 +707,7 @@ "Session Settings" "برای شروع، Gateway را متصل کنید" "اعلان امنیتی" + "پاسخ دیگر" "رد کردن هشدار تصویر اشتراکی" "Gateway نسخه دیگری از ClawHub را ارزیابی کرده است. پیش از نصب، Skill را دوباره بررسی کنید." "باز کردن دسترسی سیستم" @@ -916,6 +922,7 @@ "بررسی" "خواندن موقعیت مکانی این تلفن" "کلید مهارت" + "انقضا تا %1$sm %2$ss دیگر" "%1$s نصب شد." "Gatewayها" "حالت بازارسال" @@ -1213,6 +1220,7 @@ "... +%1$s مورد دیگر" "بازکردن فهرست بررسی برنامه" "مرورگر" + "لغو شده" "screen record" "اجرای در انتظار" "هوش مصنوعی شخصی روی دستگاه‌های شما" @@ -1335,6 +1343,7 @@ "ارائه‌دهندگان و مدل‌ها" "گره" "%1$s " + "انقضا تا %1$ss دیگر" "درخواست در دسترس نیست" "گزارش‌ها" "برای بررسی پیشنهادهای Skill Workshop، به Gateway متصل شوید." diff --git a/apps/android/app/src/main/res/values-fr/strings.xml b/apps/android/app/src/main/res/values-fr/strings.xml index 943d3fe9872d..133b57313e42 100644 --- a/apps/android/app/src/main/res/values-fr/strings.xml +++ b/apps/android/app/src/main/res/values-fr/strings.xml @@ -59,6 +59,7 @@ "Tableau JSON argv de la commande" "Impossible de lire cette image. Choisissez une capture d’écran ou une image nette du code QR fourni par openclaw qr." "Impossible d’effectuer l’action %1$s sur la proposition Skill Workshop." + "Répondu ailleurs" "Le Gateway a enregistré l\'approbation une fois." "status" "OpenClaw vérifie uniquement la localisation lorsque votre Gateway associé la demande. Sur l’écran Android suivant, choisissez %1$s pour autoriser les vérifications lorsque l’application est en arrière-plan." @@ -129,6 +130,7 @@ "Appareils associés" "%1$s en attente" "Nom de l’appareil" + "Envoyer" "Localisation" "ex. America/New_York" "Cible de session" @@ -382,6 +384,7 @@ "Aucune automatisation correspondante." "Le téléphone peut joindre la Gateway" "Gateway" + "Expiré" "Travail OpenClaw planifié depuis votre Gateway." "Sub-agent" "En attente de l’approbation de l’appareil" @@ -411,6 +414,7 @@ "Problème" "Recherchez dans les métadonnées du registre. Le Gateway vérifie à nouveau la fiabilité avant tout téléchargement." "Utilisez une adresse IP de réseau local privé pour la configuration locale, ou activez Tailscale Serve / exposez une URL de gateway en wss:// pour l’accès à distance." + "Envoi…" "Compte %1$s" "Suggest Task" "Rechercher" @@ -584,6 +588,7 @@ "Cela mettra \"%1$s\" en quarantaine et actualisera l’état de Skill Workshop depuis le Gateway." "Enregistrer une note vocale" "En attente" + "Répondu" "Autoriser les outils de caméra sur demande." "Problèmes" "Réveil vocal" @@ -702,6 +707,7 @@ "Session Settings" "Connectez le Gateway pour commencer" "Avis de sécurité" + "Autre réponse" "Ignorer l\'avertissement d\'image partagée" "Le Gateway a évalué une autre version de ClawHub. Examinez à nouveau la compétence avant de l’installer." "Ouvrir l’accès système" @@ -916,6 +922,7 @@ "Vérifier" "Lire la position de ce téléphone" "Clé du Skill" + "Expire dans %1$s min %2$s s" "%1$s a été installé." "Gateways" "Mode de transfert" @@ -1213,6 +1220,7 @@ "... +%1$s de plus" "Développer la liste de contrôle du plan" "Navigateur" + "Annulé" "screen record" "Exécution en attente" "IA personnelle sur vos appareils" @@ -1335,6 +1343,7 @@ "Fournisseurs et modèles" "Nœud" "%1$s " + "Expire dans %1$s s" "Invite indisponible" "Journaux" "Connectez le Gateway pour examiner les propositions de Skill Workshop." diff --git a/apps/android/app/src/main/res/values-hi/strings.xml b/apps/android/app/src/main/res/values-hi/strings.xml index 654343e5c81f..5012908490ab 100644 --- a/apps/android/app/src/main/res/values-hi/strings.xml +++ b/apps/android/app/src/main/res/values-hi/strings.xml @@ -59,6 +59,7 @@ "कमांड argv JSON ऐरे" "उस इमेज को पढ़ा नहीं जा सका। openclaw qr से मिले QR का स्पष्ट स्क्रीनशॉट या इमेज चुनें।" "Skill Workshop प्रस्ताव को %1$s नहीं किया जा सका।" + "कहीं और उत्तर दिया गया" "Gateway ने स्वीकृति एक बार दर्ज की।" "status" "OpenClaw केवल तभी स्थान की जाँच करता है, जब आपका युग्मित Gateway इसका अनुरोध करता है। ऐप के बैकग्राउंड में होने पर जाँच की अनुमति देने के लिए अगली Android स्क्रीन पर %1$s चुनें।" @@ -129,6 +130,7 @@ "पेयर किए गए डिवाइस" "%1$s लंबित" "डिवाइस का नाम" + "सबमिट करें" "स्थान" "जैसे America/New_York" "सेशन लक्ष्य" @@ -382,6 +384,7 @@ "कोई मेल खाता ऑटोमेशन नहीं मिला।" "फ़ोन Gateway तक पहुँच सकता है" "Gateway" + "समय समाप्त" "आपके gateway से निर्धारित OpenClaw कार्य।" "Sub-agent" "डिवाइस की स्वीकृति की प्रतीक्षा है" @@ -411,6 +414,7 @@ "समस्या" "रजिस्ट्री मेटाडेटा खोजें। किसी भी डाउनलोड से पहले Gateway विश्वसनीयता को फिर से सत्यापित करता है।" "स्थानीय सेटअप के लिए निजी LAN IP का उपयोग करें, या रिमोट एक्सेस के लिए Tailscale Serve सक्षम करें / wss:// Gateway URL उपलब्ध कराएँ।" + "सबमिट किया जा रहा है…" "खाता %1$s" "Suggest Task" "खोजें" @@ -584,6 +588,7 @@ "यह \"%1$s\" को क्वारंटीन करेगा और Gateway से Skill Workshop की स्थिति रीफ़्रेश करेगा।" "वॉइस नोट रिकॉर्ड करें" "कतार में" + "उत्तर दिया गया" "अनुरोध किए जाने पर कैमरा टूल्स की अनुमति दें।" "समस्याएँ" "वॉइस वेक" @@ -702,6 +707,7 @@ "Session Settings" "शुरू करने के लिए Gateway कनेक्ट करें" "सुरक्षा सूचना" + "अन्य उत्तर" "साझा-छवि चेतावनी खारिज करें" "Gateway ने किसी अन्य ClawHub रिलीज़ का मूल्यांकन किया था। इंस्टॉल करने से पहले Skill की दोबारा समीक्षा करें।" "सिस्टम एक्सेस खोलें" @@ -916,6 +922,7 @@ "जाँचें" "इस फ़ोन का स्थान पढ़ें" "Skill कुंजी" + "%1$sm %2$ss में समाप्त होगा" "%1$s इंस्टॉल किया गया।" "Gateways" "फ़ॉरवर्डिंग मोड" @@ -1213,6 +1220,7 @@ "... +%1$s और" "योजना चेकलिस्ट विस्तृत करें" "Browser" + "रद्द किया गया" "screen record" "रन लंबित है" "आपके डिवाइसों पर निजी AI" @@ -1335,6 +1343,7 @@ "प्रदाता और मॉडल" "नोड" "%1$s " + "%1$ss में समाप्त होगा" "प्रॉम्प्ट उपलब्ध नहीं है" "लॉग" "Skill Workshop प्रस्तावों की जाँच करने के लिए Gateway कनेक्ट करें।" diff --git a/apps/android/app/src/main/res/values-in/strings.xml b/apps/android/app/src/main/res/values-in/strings.xml index 736a4f91ae4b..2e39e813b689 100644 --- a/apps/android/app/src/main/res/values-in/strings.xml +++ b/apps/android/app/src/main/res/values-in/strings.xml @@ -59,6 +59,7 @@ "Larik JSON argv perintah" "Gambar tersebut tidak dapat dibaca. Pilih tangkapan layar atau gambar QR yang jelas dari openclaw qr." "Tidak dapat %1$s proposal Skill Workshop." + "Dijawab di tempat lain" "Gateway mencatat persetujuan sekali." "status" "OpenClaw hanya memeriksa lokasi saat Gateway yang disandingkan memintanya. Di layar Android berikutnya, pilih %1$s untuk mengizinkan pemeriksaan saat aplikasi berjalan di latar belakang." @@ -129,6 +130,7 @@ "Perangkat yang Dipasangkan" "%1$s tertunda" "Nama perangkat" + "Kirim" "Lokasi" "mis. America/New_York" "Target sesi" @@ -382,6 +384,7 @@ "Tidak ada otomatisasi yang cocok." "Ponsel dapat menjangkau Gateway" "Gateway" + "Kedaluwarsa" "Pekerjaan OpenClaw terjadwal dari gateway Anda." "Sub-agent" "Menunggu persetujuan perangkat" @@ -411,6 +414,7 @@ "Masalah" "Cari metadata registri. Gateway memverifikasi kembali kepercayaan sebelum pengunduhan apa pun." "Gunakan IP LAN privat untuk penyiapan lokal, atau aktifkan Tailscale Serve / ekspos URL gateway wss:// untuk akses jarak jauh." + "Mengirim…" "Akun %1$s" "Suggest Task" "Cari" @@ -584,6 +588,7 @@ "Tindakan ini akan mengarantina \"%1$s\" dan memuat ulang status Skill Workshop dari Gateway." "Rekam catatan suara" "Dalam antrean" + "Sudah dijawab" "Izinkan alat kamera saat diminta." "Masalah" "Bangun Suara" @@ -702,6 +707,7 @@ "Session Settings" "Hubungkan Gateway untuk memulai" "Pemberitahuan keamanan" + "Jawaban lain" "Tutup peringatan gambar yang dibagikan" "Gateway mengevaluasi rilis ClawHub yang berbeda. Tinjau kembali skill sebelum menginstalnya." "Buka Akses Sistem" @@ -916,6 +922,7 @@ "Periksa" "Baca lokasi ponsel ini" "Kunci Skill" + "Kedaluwarsa dalam %1$sm %2$ss" "%1$s telah diinstal." "Gateway" "Mode Penerusan" @@ -1213,6 +1220,7 @@ "... +%1$s lagi" "Luaskan daftar periksa rencana" "Browser" + "Dibatalkan" "screen record" "Eksekusi Tertunda" "AI pribadi di perangkat Anda" @@ -1335,6 +1343,7 @@ "Penyedia & Model" "Node" "%1$s " + "Kedaluwarsa dalam %1$ss" "Perintah tidak tersedia" "Log" "Hubungkan Gateway untuk memeriksa proposal Skill Workshop." diff --git a/apps/android/app/src/main/res/values-it/strings.xml b/apps/android/app/src/main/res/values-it/strings.xml index f7f61663d7aa..0203da9880e8 100644 --- a/apps/android/app/src/main/res/values-it/strings.xml +++ b/apps/android/app/src/main/res/values-it/strings.xml @@ -59,6 +59,7 @@ "Array JSON argv del comando" "Impossibile leggere l\'immagine. Scegli uno screenshot nitido o un\'immagine del QR generato da openclaw qr." "Impossibile %1$s la proposta di Skill Workshop." + "Risposto altrove" "Il Gateway ha registrato l\'approvazione una volta." "status" "OpenClaw controlla la posizione solo quando il Gateway associato la richiede. Nella schermata Android successiva, scegli %1$s per consentire i controlli mentre l\'app è in background." @@ -129,6 +130,7 @@ "Dispositivi associati" "%1$s in sospeso" "Nome dispositivo" + "Invia" "Posizione" "es. America/New_York" "Destinazione della sessione" @@ -382,6 +384,7 @@ "Nessuna automazione corrispondente." "Il telefono può raggiungere il Gateway" "Gateway" + "Scaduto" "Lavoro OpenClaw pianificato dal tuo Gateway." "Sub-agent" "In attesa dell\'approvazione del dispositivo" @@ -411,6 +414,7 @@ "Problema" "Cerca nei metadati del registro. Il Gateway verifica nuovamente l\'attendibilità prima di qualsiasi download." "Usa un IP LAN privato per la configurazione locale oppure abilita Tailscale Serve / esponi un URL del gateway wss:// per l\'accesso remoto." + "Invio in corso…" "Account %1$s" "Suggest Task" "Cerca" @@ -584,6 +588,7 @@ "Verrà messa in quarantena \"%1$s\" e lo stato di Skill Workshop verrà aggiornato dal gateway." "Registra nota vocale" "In coda" + "Risposto" "Consenti gli strumenti della fotocamera quando richiesto." "Problemi" "Attivazione vocale" @@ -702,6 +707,7 @@ "Session Settings" "Connetti il gateway per iniziare" "Avviso di sicurezza" + "Altra risposta" "Ignora avviso immagine condivisa" "Il Gateway ha valutato una release diversa di ClawHub. Esamina nuovamente la skill prima di installarla." "Apri accesso di sistema" @@ -916,6 +922,7 @@ "Controlla" "Leggi la posizione di questo telefono" "Chiave della skill" + "Scade tra %1$sm %2$ss" "%1$s installata." "Gateway" "Modalità di inoltro" @@ -1213,6 +1220,7 @@ "... +%1$s altri" "Espandi l\'elenco di controllo del piano" "Browser" + "Annullato" "screen record" "Esecuzione in sospeso" "AI personale sui tuoi dispositivi" @@ -1335,6 +1343,7 @@ "Provider e modelli" "Nodo" "%1$s " + "Scade tra %1$ss" "Prompt non disponibile" "Log" "Connetti il Gateway per esaminare le proposte di Skill Workshop." diff --git a/apps/android/app/src/main/res/values-ja/strings.xml b/apps/android/app/src/main/res/values-ja/strings.xml index 01772c469d25..8c3f9039a962 100644 --- a/apps/android/app/src/main/res/values-ja/strings.xml +++ b/apps/android/app/src/main/res/values-ja/strings.xml @@ -59,6 +59,7 @@ "コマンド argv JSON配列" "この画像を読み取れませんでした。openclaw qr のQRが鮮明に写っているスクリーンショットまたは画像を選択してください。" "Skill Workshopの提案を%1$sできませんでした。" + "他で回答済み" "Gateway が承認を1回記録しました。" "status" "OpenClawは、ペアリング済みのGatewayから要求された場合にのみ位置情報を確認します。アプリがバックグラウンドにある間も確認できるようにするには、次のAndroid画面で%1$sを選択してください。" @@ -129,6 +130,7 @@ "ペアリング済みデバイス" "%1$s件保留中" "デバイス名" + "送信" "位置情報" "例: America/New_York" "セッションターゲット" @@ -382,6 +384,7 @@ "一致する自動化はありません。" "スマートフォンが Gateway に到達できること" "Gateway" + "期限切れ" "Gateway からスケジュールされた OpenClaw の作業。" "Sub-agent" "デバイスの承認を待っています" @@ -411,6 +414,7 @@ "問題" "レジストリのメタデータを検索します。Gatewayはダウンロード前に信頼性を再度検証します。" "ローカル設定にはプライベートLAN IPを使用するか、リモートアクセス用にTailscale Serveを有効にするか、wss:// Gateway URLを公開してください。" + "送信中…" "アカウント %1$s" "Suggest Task" "検索" @@ -584,6 +588,7 @@ "「%1$s」を隔離し、GatewayからSkill Workshopの状態を更新します。" "ボイスノートを録音" "待機中" + "回答済み" "要求された場合にカメラツールを許可します。" "問題" "音声ウェイク" @@ -702,6 +707,7 @@ "Session Settings" "開始するにはGatewayに接続してください" "セキュリティに関する通知" + "その他の回答" "共有画像の警告を閉じる" "Gatewayが別のClawHubリリースを評価しました。インストールする前に、Skillをもう一度確認してください。" "システムアクセスを開く" @@ -916,6 +922,7 @@ "確認" "このスマートフォンの位置情報を読み取る" "Skillキー" + "期限切れまで%1$sm %2$ss" "%1$sをインストールしました。" "Gateway" "転送モード" @@ -1213,6 +1220,7 @@ "... +%1$s 件追加" "計画チェックリストを展開" "ブラウザ" + "キャンセル済み" "screen record" "実行保留中" "あなたのデバイス上のパーソナル AI" @@ -1335,6 +1343,7 @@ "プロバイダーとモデル" "ノード" "%1$s " + "期限切れまで%1$ss" "プロンプトを利用できません" "ログ" "Skill Workshopの提案を確認するには、Gatewayに接続してください。" diff --git a/apps/android/app/src/main/res/values-ko/strings.xml b/apps/android/app/src/main/res/values-ko/strings.xml index 7b7936429d0f..8b22f2c7716a 100644 --- a/apps/android/app/src/main/res/values-ko/strings.xml +++ b/apps/android/app/src/main/res/values-ko/strings.xml @@ -59,6 +59,7 @@ "명령 argv JSON 배열" "해당 이미지를 읽을 수 없습니다. openclaw qr의 QR이 선명하게 보이는 스크린샷이나 이미지를 선택하세요." "Skill Workshop 제안을 %1$s하지 못했습니다." + "다른 곳에서 응답 완료" "Gateway가 한 번 승인을 기록했습니다." "status" "OpenClaw는 페어링된 Gateway가 요청할 때만 위치를 확인합니다. 앱이 백그라운드에 있는 동안에도 위치를 확인할 수 있도록 다음 Android 화면에서 %1$s을 선택하세요." @@ -129,6 +130,7 @@ "페어링된 기기" "%1$s개 대기 중" "기기 이름" + "제출" "위치" "예: America/New_York" "세션 대상" @@ -382,6 +384,7 @@ "일치하는 자동화가 없습니다." "휴대폰에서 Gateway에 연결할 수 있음" "Gateway" + "만료됨" "Gateway에서 예약된 OpenClaw 작업." "Sub-agent" "기기 승인 대기 중" @@ -411,6 +414,7 @@ "문제" "레지스트리 메타데이터를 검색합니다. Gateway는 다운로드 전에 신뢰성을 다시 확인합니다." "로컬 설정에는 사설 LAN IP를 사용하거나, 원격 액세스를 위해 Tailscale Serve를 활성화하거나 wss:// Gateway URL을 노출하세요." + "제출 중…" "계정 %1$s" "Suggest Task" "검색" @@ -584,6 +588,7 @@ "이 작업은 \"%1$s\"을(를) 격리하고 Gateway에서 Skill Workshop 상태를 새로 고칩니다." "음성 메모 녹음" "대기 중" + "응답 완료" "요청 시 카메라 도구를 허용합니다." "문제" "음성 깨우기" @@ -702,6 +707,7 @@ "Session Settings" "시작하려면 Gateway를 연결하세요" "보안 알림" + "기타 답변" "공유 이미지 경고 닫기" "Gateway가 다른 ClawHub 릴리스를 평가했습니다. 설치하기 전에 해당 Skill을 다시 검토하세요." "시스템 접근 권한 열기" @@ -916,6 +922,7 @@ "확인" "이 휴대전화의 위치 확인" "Skill 키" + "%1$s분 %2$s초 후 만료" "%1$s을(를) 설치했습니다." "Gateway" "전달 모드" @@ -1213,6 +1220,7 @@ "... +%1$s개 더" "계획 체크리스트 펼치기" "브라우저" + "취소됨" "screen record" "실행 대기 중" "기기에서 사용하는 개인 AI" @@ -1335,6 +1343,7 @@ "제공업체 및 모델" "노드" "%1$s " + "%1$s초 후 만료" "프롬프트를 사용할 수 없음" "로그" "Skill Workshop 제안을 확인하려면 Gateway를 연결하세요." diff --git a/apps/android/app/src/main/res/values-nl/strings.xml b/apps/android/app/src/main/res/values-nl/strings.xml index 138dee18b1a8..46955f66a3d1 100644 --- a/apps/android/app/src/main/res/values-nl/strings.xml +++ b/apps/android/app/src/main/res/values-nl/strings.xml @@ -59,6 +59,7 @@ "Commando argv JSON-array" "Kan die afbeelding niet lezen. Kies een duidelijke schermafbeelding of afbeelding van de QR-code van openclaw qr." "Kan het Skill Workshop-voorstel niet %1$s." + "Elders beantwoord" "Gateway heeft eenmalig goedkeuring vastgelegd." "status" "OpenClaw controleert je locatie alleen wanneer je gekoppelde Gateway daarom vraagt. Kies op het volgende Android-scherm %1$s om controles toe te staan terwijl de app op de achtergrond actief is." @@ -129,6 +130,7 @@ "Gekoppelde apparaten" "%1$s openstaand" "Apparaatnaam" + "Indienen" "Locatie" "bijv. America/New_York" "Sessiedoel" @@ -382,6 +384,7 @@ "Geen overeenkomende automatiseringen." "Telefoon kan de Gateway bereiken" "Gateway" + "Verlopen" "Gepland OpenClaw-werk vanaf je Gateway." "Sub-agent" "Wachten op apparaatgoedkeuring" @@ -411,6 +414,7 @@ "Probleem" "Doorzoek registermetadata. De Gateway verifieert de betrouwbaarheid opnieuw vóór elke download." "Gebruik een privé-IP-adres op het LAN voor lokale configuratie, of schakel Tailscale Serve in / maak een wss://-gateway-URL beschikbaar voor externe toegang." + "Bezig met indienen…" "Account %1$s" "Suggest Task" "Zoeken" @@ -584,6 +588,7 @@ "Hiermee wordt \"%1$s\" in quarantaine geplaatst en wordt de status van Skill Workshop vernieuwd vanuit de gateway." "Spraakbericht opnemen" "In wachtrij" + "Beantwoord" "Cameratools toestaan wanneer daarom wordt gevraagd." "Problemen" "Spraakactivering" @@ -702,6 +707,7 @@ "Session Settings" "Verbind de Gateway om te beginnen" "Beveiligingsmelding" + "Ander antwoord" "Waarschuwing gedeelde afbeelding sluiten" "De Gateway heeft een andere ClawHub-release beoordeeld. Bekijk de skill opnieuw voordat u deze installeert." "Systeemtoegang openen" @@ -916,6 +922,7 @@ "Controleren" "Locatie van deze telefoon lezen" "Skill-sleutel" + "Verloopt over %1$sm %2$ss" "%1$s is geïnstalleerd." "Gateways" "Doorstuurmodus" @@ -1213,6 +1220,7 @@ "... +%1$s meer" "Checklist van plan uitklappen" "Browser" + "Geannuleerd" "screen record" "Uitvoering in behandeling" "Persoonlijke AI op je apparaten" @@ -1335,6 +1343,7 @@ "Providers en modellen" "Knooppunt" "%1$s " + "Verloopt over %1$ss" "Prompt niet beschikbaar" "Logboeken" "Verbind de Gateway om voorstellen van Skill Workshop te bekijken." diff --git a/apps/android/app/src/main/res/values-pl/strings.xml b/apps/android/app/src/main/res/values-pl/strings.xml index 17a54e85c1d6..df20c490283e 100644 --- a/apps/android/app/src/main/res/values-pl/strings.xml +++ b/apps/android/app/src/main/res/values-pl/strings.xml @@ -59,6 +59,7 @@ "Tablica JSON argv polecenia" "Nie udało się odczytać tego obrazu. Wybierz wyraźny zrzut ekranu lub obraz kodu QR z openclaw qr." "Nie udało się wykonać działania %1$s dla propozycji Skill Workshop." + "Odpowiedziano gdzie indziej" "Gateway zarejestrował zatwierdzenie jednorazowo." "status" "OpenClaw sprawdza lokalizację tylko wtedy, gdy zażąda tego sparowany Gateway. Na następnym ekranie systemu Android wybierz %1$s, aby zezwolić na sprawdzanie, gdy aplikacja działa w tle." @@ -129,6 +130,7 @@ "Sparowane urządzenia" "%1$s oczekujących" "Nazwa urządzenia" + "Prześlij" "Lokalizacja" "np. America/New_York" "Cel sesji" @@ -382,6 +384,7 @@ "Brak pasujących automatyzacji." "Telefon może połączyć się z Gateway" "Gateway" + "Wygasło" "Zaplanowane zadania OpenClaw z Twojego gateway." "Sub-agent" "Oczekiwanie na zatwierdzenie urządzenia" @@ -411,6 +414,7 @@ "Problem" "Przeszukuj metadane rejestru. Gateway ponownie weryfikuje zaufanie przed każdym pobraniem." "Do konfiguracji lokalnej użyj prywatnego adresu IP w sieci LAN albo włącz Tailscale Serve / udostępnij adres URL bramy Gateway z protokołem wss://, aby umożliwić dostęp zdalny." + "Przesyłanie…" "Konto %1$s" "Suggest Task" "Szukaj" @@ -584,6 +588,7 @@ "Spowoduje to poddanie propozycji „%1$s” kwarantannie i odświeżenie stanu Skill Workshop z gateway." "Nagraj notatkę głosową" "W kolejce" + "Udzielono odpowiedzi" "Zezwalaj na używanie narzędzi aparatu na żądanie." "Problemy" "Wybudzanie głosem" @@ -702,6 +707,7 @@ "Session Settings" "Połącz Gateway, aby rozpocząć" "Informacja o bezpieczeństwie" + "Inna odpowiedź" "Odrzuć ostrzeżenie o udostępnionym obrazie" "Gateway ocenił inne wydanie ClawHub. Sprawdź umiejętność ponownie przed instalacją." "Otwórz dostęp systemowy" @@ -916,6 +922,7 @@ "Sprawdź" "Odczytuj lokalizację tego telefonu" "Klucz Skill" + "Wygasa za %1$sm %2$ss" "Zainstalowano %1$s." "Gateway" "Tryb przekazywania" @@ -1213,6 +1220,7 @@ "... +%1$s więcej" "Rozwiń listę kontrolną planu" "Przeglądarka" + "Anulowano" "screen record" "Oczekujące uruchomienie" "Osobista AI na Twoich urządzeniach" @@ -1335,6 +1343,7 @@ "Dostawcy i modele" "Węzeł" "%1$s " + "Wygasa za %1$ss" "Polecenie niedostępne" "Dzienniki" "Połącz Gateway, aby sprawdzić propozycje Skill Workshop." diff --git a/apps/android/app/src/main/res/values-pt-rBR/strings.xml b/apps/android/app/src/main/res/values-pt-rBR/strings.xml index c3f6fd2b386c..ad078734b3bd 100644 --- a/apps/android/app/src/main/res/values-pt-rBR/strings.xml +++ b/apps/android/app/src/main/res/values-pt-rBR/strings.xml @@ -59,6 +59,7 @@ "Array JSON argv do comando" "Não foi possível ler essa imagem. Escolha uma captura de tela nítida ou uma imagem do QR gerado por openclaw qr." "Não foi possível %1$s a proposta do Skill Workshop." + "Respondido em outro lugar" "O Gateway registrou a aprovação uma vez." "status" "O OpenClaw só verifica a localização quando o Gateway pareado solicita. Na próxima tela do Android, escolha %1$s para permitir verificações enquanto o app estiver em segundo plano." @@ -129,6 +130,7 @@ "Dispositivos pareados" "%1$s pendentes" "Nome do dispositivo" + "Enviar" "Localização" "ex.: America/New_York" "Destino da sessão" @@ -382,6 +384,7 @@ "Nenhuma automação correspondente." "O telefone pode acessar o Gateway" "Gateway" + "Expirado" "Trabalho agendado do OpenClaw a partir do seu gateway." "Sub-agent" "Aguardando aprovação do dispositivo" @@ -411,6 +414,7 @@ "Problema" "Pesquise os metadados do registro. O Gateway verifica novamente a confiabilidade antes de qualquer download." "Use um IP de LAN privada para a configuração local ou ative o Tailscale Serve / exponha uma URL de gateway wss:// para acesso remoto." + "Enviando…" "Conta %1$s" "Suggest Task" "Pesquisar" @@ -584,6 +588,7 @@ "Isso colocará \"%1$s\" em quarentena e atualizará o estado do Skill Workshop com base no gateway." "Gravar nota de voz" "Na fila" + "Respondido" "Permitir ferramentas de câmera quando solicitado." "Problemas" "Ativação por voz" @@ -702,6 +707,7 @@ "Session Settings" "Conecte o Gateway para começar" "Aviso de segurança" + "Outra resposta" "Dispensar aviso de imagem compartilhada" "O Gateway avaliou uma versão diferente do ClawHub. Revise a skill novamente antes de instalar." "Abrir acesso do sistema" @@ -916,6 +922,7 @@ "Verificar" "Ler a localização deste telefone" "Chave da Skill" + "Expira em %1$sm %2$ss" "%1$s foi instalada." "Gateways" "Modo de encaminhamento" @@ -1213,6 +1220,7 @@ "... +%1$s mais" "Expandir lista de verificação do plano" "Navegador" + "Cancelado" "screen record" "Execução pendente" "IA pessoal nos seus dispositivos" @@ -1335,6 +1343,7 @@ "Provedores e modelos" "Nó" "%1$s " + "Expira em %1$ss" "Prompt indisponível" "Logs" "Conecte o Gateway para inspecionar as propostas do Skill Workshop." diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index a42d05ce2976..b78374421f04 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -59,6 +59,7 @@ "JSON-массив argv команды" "Не удалось прочитать это изображение. Выберите чёткий снимок экрана или изображение QR-кода из openclaw qr." "Не удалось выполнить действие %1$s с предложением Skill Workshop." + "Отвечено в другом месте" "Gateway зафиксировал одобрение однократно." "status" "OpenClaw проверяет местоположение, только когда его запрашивает сопряжённый Gateway. На следующем экране Android выберите %1$s, чтобы разрешить проверки, когда приложение работает в фоновом режиме." @@ -129,6 +130,7 @@ "Сопряженные устройства" "%1$s ожидают подтверждения" "Имя устройства" + "Отправить" "Местоположение" "например America/New_York" "Целевая сессия" @@ -382,6 +384,7 @@ "Подходящие автоматизации не найдены." "Телефон может подключиться к Gateway" "Gateway" + "Срок истёк" "Запланированная работа OpenClaw с вашего gateway." "Sub-agent" "Ожидание подтверждения устройства" @@ -411,6 +414,7 @@ "Проблема" "Ищите в метаданных реестра. Gateway повторно проверяет надёжность перед каждой загрузкой." "Для локальной настройки используйте частный IP-адрес локальной сети, а для удалённого доступа включите Tailscale Serve или откройте доступ к URL-адресу Gateway с wss://." + "Отправка…" "Учётная запись %1$s" "Suggest Task" "Поиск" @@ -584,6 +588,7 @@ "Предложение \"%1$s\" будет помещено в карантин, а состояние Skill Workshop будет обновлено из Gateway." "Записать голосовое сообщение" "В очереди" + "Отвечено" "Разрешать доступ к камере по запросу." "Проблемы" "Голосовая активация" @@ -702,6 +707,7 @@ "Session Settings" "Подключите Gateway, чтобы начать" "Уведомление о безопасности" + "Другой ответ" "Закрыть предупреждение об общем изображении" "Gateway проверил другой выпуск ClawHub. Ещё раз просмотрите навык перед установкой." "Открыть системный доступ" @@ -916,6 +922,7 @@ "Проверить" "Получать местоположение этого телефона" "Ключ Skill" + "Истекает через %1$s мин %2$s с" "Установлен %1$s." "Gateway" "Режим пересылки" @@ -1213,6 +1220,7 @@ "... +%1$s еще" "Развернуть контрольный список плана" "Браузер" + "Отменено" "screen record" "Запуск ожидается" "Персональный ИИ на ваших устройствах" @@ -1335,6 +1343,7 @@ "Провайдеры и модели" "Узел" "%1$s " + "Истекает через %1$s с" "Запрос недоступен" "Журналы" "Подключите Gateway, чтобы просмотреть предложения Skill Workshop." diff --git a/apps/android/app/src/main/res/values-sv/strings.xml b/apps/android/app/src/main/res/values-sv/strings.xml index 8e5f846f4a2a..01ae68585bcf 100644 --- a/apps/android/app/src/main/res/values-sv/strings.xml +++ b/apps/android/app/src/main/res/values-sv/strings.xml @@ -59,6 +59,7 @@ "Kommando argv JSON-array" "Det gick inte att läsa bilden. Välj en tydlig skärmbild eller bild av QR-koden från openclaw qr." "Det gick inte att %1$s förslaget från Skill Workshop." + "Besvarad på annat håll" "Gateway registrerade godkännandet en gång." "status" "OpenClaw kontrollerar bara platsen när din parkopplade Gateway begär det. På nästa Android-skärm väljer du %1$s för att tillåta kontroller medan appen körs i bakgrunden." @@ -129,6 +130,7 @@ "Parkopplade enheter" "%1$s väntande" "Enhetsnamn" + "Skicka" "Plats" "t.ex. America/New_York" "Sessionsmål" @@ -382,6 +384,7 @@ "Inga matchande automatiseringar." "Telefonen kan nå Gateway" "Gateway" + "Utgången" "Schemalagt OpenClaw-arbete från din gateway." "Sub-agent" "Väntar på enhetsgodkännande" @@ -411,6 +414,7 @@ "Problem" "Sök i registrets metadata. Gateway verifierar tilliten igen före varje nedladdning." "Använd en privat LAN-IP-adress för lokal konfiguration eller aktivera Tailscale Serve / exponera en Gateway-URL med wss:// för fjärråtkomst." + "Skickar…" "Konto %1$s" "Suggest Task" "Sök" @@ -584,6 +588,7 @@ "Detta sätter \"%1$s\" i karantän och uppdaterar statusen för Skill Workshop från Gateway." "Spela in röstmeddelande" "I kö" + "Besvarad" "Tillåt kameraverktyg på begäran." "Problem" "Röstaktivering" @@ -702,6 +707,7 @@ "Session Settings" "Anslut Gateway för att börja" "Säkerhetsmeddelande" + "Annat svar" "Avfärda varning om delad bild" "Gateway utvärderade en annan ClawHub-version. Granska denna skill igen innan du installerar." "Öppna systemåtkomst" @@ -916,6 +922,7 @@ "Kontrollera" "Läs av den här telefonens plats" "Skill-nyckel" + "Upphör om %1$sm %2$ss" "Installerade %1$s." "Gateways" "Vidarebefordringsläge" @@ -1213,6 +1220,7 @@ "... +%1$s till" "Visa planens checklista" "Webbläsare" + "Avbruten" "screen record" "Körning väntar" "Personlig AI på dina enheter" @@ -1335,6 +1343,7 @@ "Leverantörer och modeller" "Nod" "%1$s " + "Upphör om %1$ss" "Prompten är inte tillgänglig" "Loggar" "Anslut Gateway för att granska förslag från Skill Workshop." diff --git a/apps/android/app/src/main/res/values-th/strings.xml b/apps/android/app/src/main/res/values-th/strings.xml index 4dc6d849d20f..a25260c5a73e 100644 --- a/apps/android/app/src/main/res/values-th/strings.xml +++ b/apps/android/app/src/main/res/values-th/strings.xml @@ -59,6 +59,7 @@ "อาร์เรย์ JSON ของ argv คำสั่ง" "ไม่สามารถอ่านรูปภาพนั้นได้ โปรดเลือกภาพหน้าจอหรือรูปภาพคิวอาร์จาก openclaw qr ที่ชัดเจน" "ไม่สามารถ %1$s ข้อเสนอ Skill Workshop ได้" + "ตอบจากที่อื่นแล้ว" "Gateway บันทึกการอนุมัติหนึ่งครั้ง" "status" "OpenClaw จะตรวจสอบตำแหน่งเมื่อ Gateway ที่จับคู่ไว้ร้องขอเท่านั้น ในหน้าจอ Android ถัดไป ให้เลือก %1$s เพื่ออนุญาตให้ตรวจสอบขณะที่แอปทำงานอยู่เบื้องหลัง" @@ -129,6 +130,7 @@ "อุปกรณ์ที่จับคู่แล้ว" "รอดำเนินการ %1$s รายการ" "ชื่ออุปกรณ์" + "ส่ง" "ตำแหน่งที่ตั้ง" "เช่น America/New_York" "เป้าหมายเซสชัน" @@ -382,6 +384,7 @@ "ไม่พบระบบอัตโนมัติที่ตรงกัน" "โทรศัพท์สามารถเข้าถึง Gateway ได้" "Gateway" + "หมดอายุแล้ว" "งาน OpenClaw ที่กำหนดเวลาไว้จาก gateway ของคุณ" "Sub-agent" "กำลังรอการอนุมัติอุปกรณ์" @@ -411,6 +414,7 @@ "ปัญหา" "ค้นหาข้อมูลเมตาของรีจิสทรี โดย Gateway จะตรวจสอบความน่าเชื่อถืออีกครั้งก่อนดาวน์โหลด" "ใช้ IP ของ LAN ส่วนตัวสำหรับการตั้งค่าภายในเครื่อง หรือเปิดใช้งาน Tailscale Serve / เปิดเผย URL ของ Gateway แบบ wss:// สำหรับการเข้าถึงจากระยะไกล" + "กำลังส่ง…" "บัญชี %1$s" "Suggest Task" "ค้นหา" @@ -584,6 +588,7 @@ "การดำเนินการนี้จะกักกัน \"%1$s\" และรีเฟรชสถานะ Skill Workshop จาก Gateway" "บันทึกข้อความเสียง" "อยู่ในคิว" + "ตอบแล้ว" "อนุญาตให้ใช้เครื่องมือกล้องเมื่อมีการร้องขอ" "ปัญหา" "การปลุกด้วยเสียง" @@ -702,6 +707,7 @@ "Session Settings" "เชื่อมต่อ Gateway เพื่อเริ่มต้น" "ประกาศด้านความปลอดภัย" + "คำตอบอื่น" "ปิดคำเตือนรูปภาพที่แชร์" "Gateway ประเมิน ClawHub รุ่นอื่น โปรดตรวจสอบ Skill อีกครั้งก่อนติดตั้ง" "เปิดการเข้าถึงระบบ" @@ -916,6 +922,7 @@ "ตรวจสอบ" "อ่านตำแหน่งของโทรศัพท์เครื่องนี้" "คีย์ของ Skill" + "หมดอายุใน %1$s นาที %2$s วินาที" "ติดตั้ง %1$s แล้ว" "Gateway" "โหมดการส่งต่อ" @@ -1213,6 +1220,7 @@ "... +%1$s เพิ่มเติม" "ขยายรายการตรวจสอบแผน" "เบราว์เซอร์" + "ยกเลิกแล้ว" "screen record" "รอการเรียกใช้" "AI ส่วนตัวบนอุปกรณ์ของคุณ" @@ -1335,6 +1343,7 @@ "ผู้ให้บริการและโมเดล" "โหนด" "%1$s " + "หมดอายุใน %1$s วินาที" "ไม่มีพรอมต์" "บันทึก" "เชื่อมต่อ Gateway เพื่อตรวจสอบข้อเสนอ Skill Workshop" diff --git a/apps/android/app/src/main/res/values-tr/strings.xml b/apps/android/app/src/main/res/values-tr/strings.xml index a8a9e6820676..f13267650a39 100644 --- a/apps/android/app/src/main/res/values-tr/strings.xml +++ b/apps/android/app/src/main/res/values-tr/strings.xml @@ -59,6 +59,7 @@ "Komut argv JSON dizisi" "Bu görüntü okunamadı. openclaw qr tarafından oluşturulan QR kodunun net bir ekran görüntüsünü veya görselini seçin." "Skill Workshop teklifi için %1$s işlemi gerçekleştirilemedi." + "Başka yerde yanıtlandı" "Gateway onayı bir kez kaydetti." "status" "OpenClaw, konumu yalnızca eşleştirilmiş Gateway\'iniz istediğinde kontrol eder. Uygulama arka plandayken kontrollere izin vermek için bir sonraki Android ekranında %1$s seçeneğini belirleyin." @@ -129,6 +130,7 @@ "Eşlenmiş Cihazlar" "%1$s bekliyor" "Cihaz adı" + "Gönder" "Konum" "örn. America/New_York" "Oturum hedefi" @@ -382,6 +384,7 @@ "Eşleşen otomasyon yok." "Telefon Gateway\'ye erişebiliyor" "Gateway" + "Süresi doldu" "Gateway\'inizden zamanlanmış OpenClaw işleri." "Sub-agent" "Cihaz onayı bekleniyor" @@ -411,6 +414,7 @@ "Sorun" "Kayıt defteri meta verilerinde arama yapın. Gateway, herhangi bir indirmeden önce güveni tekrar doğrular." "Yerel kurulum için özel bir LAN IP\'si kullanın veya uzaktan erişim için Tailscale Serve\'ü etkinleştirin / bir wss:// gateway URL\'sini kullanıma açın." + "Gönderiliyor…" "Hesap %1$s" "Suggest Task" "Ara" @@ -584,6 +588,7 @@ "Bu işlem \"%1$s\" teklifini karantinaya alacak ve Skill Workshop durumunu Gateway\'den yenileyecek." "Sesli not kaydet" "Sıraya alındı" + "Yanıtlandı" "İstendiğinde kamera araçlarına izin ver." "Sorunlar" "Sesle Uyandırma" @@ -702,6 +707,7 @@ "Session Settings" "Başlamak için Gateway\'i bağlayın" "Güvenlik bildirimi" + "Diğer yanıt" "Paylaşılan görüntü uyarısını kapat" "Gateway farklı bir ClawHub sürümünü değerlendirdi. Yüklemeden önce Skill\'i tekrar inceleyin." "Sistem Erişimini Aç" @@ -916,6 +922,7 @@ "Kontrol et" "Bu telefonun konumunu okuyun" "Skill Anahtarı" + "Sona ermesine %1$s dk %2$s sn" "%1$s yüklendi." "Gateway\'ler" "Yönlendirme Modu" @@ -1213,6 +1220,7 @@ "... +%1$s daha" "Plan kontrol listesini genişlet" "Tarayıcı" + "İptal edildi" "screen record" "Çalıştırma Bekliyor" "Cihazlarınızda kişisel AI" @@ -1335,6 +1343,7 @@ "Sağlayıcılar ve Modeller" "Düğüm" "%1$s " + "Sona ermesine %1$s sn" "İstem kullanılamıyor" "Günlükler" "Skill Workshop önerilerini incelemek için Gateway\'e bağlanın." diff --git a/apps/android/app/src/main/res/values-uk/strings.xml b/apps/android/app/src/main/res/values-uk/strings.xml index 5c3354df0f2f..343fa0d42903 100644 --- a/apps/android/app/src/main/res/values-uk/strings.xml +++ b/apps/android/app/src/main/res/values-uk/strings.xml @@ -59,6 +59,7 @@ "JSON-масив argv команди" "Не вдалося прочитати це зображення. Виберіть чіткий знімок екрана або зображення QR-коду з openclaw qr." "Не вдалося %1$s пропозицію Skill Workshop." + "Відповідь надано в іншому місці" "Gateway зафіксував схвалення один раз." "status" "OpenClaw перевіряє місцезнаходження, лише коли цього запитує підключений Gateway. На наступному екрані Android виберіть %1$s, щоб дозволити перевірки, коли застосунок працює у фоновому режимі." @@ -129,6 +130,7 @@ "Спарені пристрої" "%1$s очікують" "Назва пристрою" + "Надіслати" "Місцезнаходження" "напр. America/New_York" "Ціль сеансу" @@ -382,6 +384,7 @@ "Відповідних автоматизацій не знайдено." "Телефон може підключитися до Gateway" "Gateway" + "Термін дії минув" "Запланована робота OpenClaw з вашого gateway." "Sub-agent" "Очікування схвалення пристрою" @@ -411,6 +414,7 @@ "Проблема" "Пошук у метаданих реєстру. Gateway повторно перевіряє надійність перед будь-яким завантаженням." "Для локального налаштування використовуйте приватну IP-адресу локальної мережі або ввімкніть Tailscale Serve / надайте доступ до URL-адреси шлюзу wss:// для віддаленого доступу." + "Надсилання…" "Обліковий запис %1$s" "Suggest Task" "Пошук" @@ -584,6 +588,7 @@ "Буде ізольовано \"%1$s\", а стан Майстерні навичок оновлено з Gateway." "Записати голосове повідомлення" "У черзі" + "Відповідь надано" "Дозволяти інструменти камери за запитом." "Проблеми" "Голосова активація" @@ -702,6 +707,7 @@ "Session Settings" "Підключіть Gateway, щоб почати" "Повідомлення про безпеку" + "Інша відповідь" "Закрити попередження про спільне зображення" "Gateway перевірив інший випуск ClawHub. Перегляньте навичку ще раз перед установленням." "Відкрити системний доступ" @@ -916,6 +922,7 @@ "Перевірити" "Отримувати дані про місцезнаходження цього телефону" "Ключ навички" + "Залишилося %1$s хв %2$s с" "Установлено %1$s." "Шлюзи" "Режим пересилання" @@ -1213,6 +1220,7 @@ "... +%1$s ще" "Розгорнути контрольний список плану" "Браузер" + "Скасовано" "screen record" "Очікує запуску" "Персональний ШІ на ваших пристроях" @@ -1335,6 +1343,7 @@ "Постачальники й моделі" "Вузол" "%1$s " + "Залишилося %1$s с" "Запит недоступний" "Журнали" "Підключіть Gateway, щоб переглянути пропозиції Skill Workshop." diff --git a/apps/android/app/src/main/res/values-vi/strings.xml b/apps/android/app/src/main/res/values-vi/strings.xml index 2f84e76994b3..b18f06daf94c 100644 --- a/apps/android/app/src/main/res/values-vi/strings.xml +++ b/apps/android/app/src/main/res/values-vi/strings.xml @@ -59,6 +59,7 @@ "Mảng JSON argv của lệnh" "Không thể đọc hình ảnh đó. Hãy chọn ảnh chụp màn hình rõ nét hoặc hình ảnh mã QR từ openclaw qr." "Không thể %1$s đề xuất Skill Workshop." + "Đã trả lời ở nơi khác" "Gateway đã ghi nhận phê duyệt một lần." "status" "OpenClaw chỉ kiểm tra vị trí khi Gateway đã ghép nối yêu cầu. Trên màn hình Android tiếp theo, hãy chọn %1$s để cho phép kiểm tra khi ứng dụng chạy trong nền." @@ -129,6 +130,7 @@ "Thiết bị đã ghép đôi" "%1$s đang chờ" "Tên thiết bị" + "Gửi" "Vị trí" "ví dụ: America/New_York" "Đích phiên" @@ -382,6 +384,7 @@ "Không có tác vụ tự động nào phù hợp." "Điện thoại có thể truy cập Gateway" "Gateway" + "Đã hết hạn" "Công việc OpenClaw đã lên lịch từ gateway của bạn." "Sub-agent" "Đang chờ phê duyệt thiết bị" @@ -411,6 +414,7 @@ "Sự cố" "Tìm kiếm siêu dữ liệu trong registry. Gateway sẽ xác minh lại độ tin cậy trước mọi lượt tải xuống." "Sử dụng địa chỉ IP LAN riêng để thiết lập cục bộ, hoặc bật Tailscale Serve / công khai URL Gateway wss:// để truy cập từ xa." + "Đang gửi…" "Tài khoản %1$s" "Suggest Task" "Tìm kiếm" @@ -584,6 +588,7 @@ "Thao tác này sẽ cách ly \"%1$s\" và làm mới trạng thái Skill Workshop từ Gateway." "Ghi ghi chú thoại" "Đang chờ" + "Đã trả lời" "Cho phép dùng công cụ camera khi được yêu cầu." "Sự cố" "Đánh thức bằng giọng nói" @@ -702,6 +707,7 @@ "Session Settings" "Kết nối Gateway để bắt đầu" "Thông báo bảo mật" + "Câu trả lời khác" "Bỏ qua cảnh báo hình ảnh được chia sẻ" "Gateway đã đánh giá một bản phát hành ClawHub khác. Hãy xem lại skill trước khi cài đặt." "Mở quyền truy cập hệ thống" @@ -916,6 +922,7 @@ "Kiểm tra" "Đọc vị trí của điện thoại này" "Khóa Skill" + "Hết hạn sau %1$sm %2$ss" "Đã cài đặt %1$s." "Gateway" "Chế độ chuyển tiếp" @@ -1213,6 +1220,7 @@ "... +%1$s nữa" "Mở rộng danh sách kiểm tra kế hoạch" "Trình duyệt" + "Đã hủy" "screen record" "Lượt chạy đang chờ" "AI cá nhân trên các thiết bị của bạn" @@ -1335,6 +1343,7 @@ "Nhà cung cấp & Mô hình" "Nút" "%1$s " + "Hết hạn sau %1$ss" "Câu lệnh không khả dụng" "Nhật ký" "Kết nối Gateway để kiểm tra các đề xuất Skill Workshop." diff --git a/apps/android/app/src/main/res/values-zh-rCN/strings.xml b/apps/android/app/src/main/res/values-zh-rCN/strings.xml index 11df60072e27..9e6c461efd58 100644 --- a/apps/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rCN/strings.xml @@ -59,6 +59,7 @@ "命令 argv JSON 数组" "无法读取该图片。请选择清晰的截图或 openclaw qr 所生成二维码的图片。" "无法 %1$s Skill Workshop 提案。" + "已在其他地方回答" "Gateway 已记录一次批准。" "status" "OpenClaw 仅在已配对的 Gateway 请求时检查位置。在下一个 Android 屏幕上,选择 %1$s,以允许应用在后台运行时进行检查。" @@ -129,6 +130,7 @@ "已配对设备" "%1$s 项待审批" "设备名称" + "提交" "位置" "例如 America/New_York" "会话目标" @@ -382,6 +384,7 @@ "没有匹配的自动化。" "手机可以连接到 Gateway" "Gateway" + "已过期" "来自你的 gateway 的已计划 OpenClaw 工作。" "Sub-agent" "正在等待设备批准" @@ -411,6 +414,7 @@ "问题" "搜索注册表元数据。Gateway 会在下载前再次验证信任状态。" "本地设置请使用专用局域网 IP;远程访问请启用 Tailscale Serve,或公开一个 wss:// Gateway URL。" + "正在提交…" "账户 %1$s" "Suggest Task" "搜索" @@ -584,6 +588,7 @@ "这将隔离“%1$s”,并从 Gateway 刷新 Skill Workshop 状态。" "录制语音留言" "已排队" + "已回答" "在请求时允许使用相机工具。" "问题" "语音唤醒" @@ -702,6 +707,7 @@ "Session Settings" "连接 Gateway 以开始" "安全提示" + "其他答案" "关闭共享图片警告" "Gateway 评估了不同的 ClawHub 版本。请重新检查该 Skill 后再安装。" "打开系统访问权限" @@ -916,6 +922,7 @@ "检查" "读取此手机的位置信息" "Skill 键" + "%1$s分%2$s秒后过期" "已安装 %1$s。" "Gateways" "转发模式" @@ -1213,6 +1220,7 @@ "... +%1$s 个更多" "展开计划清单" "浏览器" + "已取消" "screen record" "运行待处理项" "您设备上的个人 AI" @@ -1335,6 +1343,7 @@ "提供商与模型" "节点" "%1$s " + "%1$s秒后过期" "提示词不可用" "日志" "连接 Gateway 以检查 Skill Workshop 提案。" diff --git a/apps/android/app/src/main/res/values-zh-rTW/strings.xml b/apps/android/app/src/main/res/values-zh-rTW/strings.xml index cb0ff7b6b3ad..3a19b758325c 100644 --- a/apps/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rTW/strings.xml @@ -59,6 +59,7 @@ "命令 argv JSON 陣列" "無法讀取該圖片。請選擇清晰的螢幕截圖或 openclaw qr 所產生的 QR 圖片。" "無法%1$s Skill Workshop 提案。" + "已在其他地方回答" "Gateway 已記錄核准一次。" "status" "OpenClaw 只會在已配對的 Gateway 要求時檢查位置。在下一個 Android 畫面上,選擇 %1$s,以允許應用程式在背景執行時檢查位置。" @@ -129,6 +130,7 @@ "已配對裝置" "%1$s 個待核准項目" "裝置名稱" + "提交" "位置" "例如 America/New_York" "工作階段目標" @@ -382,6 +384,7 @@ "沒有相符的自動化。" "手機可以連線到 Gateway" "Gateway" + "已過期" "來自您 gateway 的已排程 OpenClaw 工作。" "Sub-agent" "正在等待裝置核准" @@ -411,6 +414,7 @@ "問題" "搜尋登錄中繼資料。Gateway 會在下載前再次驗證信任狀態。" "本機設定請使用私人區域網路 IP;若要遠端存取,請啟用 Tailscale Serve,或公開一個 wss:// Gateway URL。" + "正在提交…" "帳戶 %1$s" "Suggest Task" "搜尋" @@ -584,6 +588,7 @@ "這將隔離「%1$s」,並從 Gateway 重新整理 Skill Workshop 狀態。" "錄製語音留言" "已排入佇列" + "已回答" "在要求時允許使用相機工具。" "問題" "語音喚醒" @@ -702,6 +707,7 @@ "Session Settings" "連接 Gateway 以開始" "安全性通知" + "其他答案" "關閉分享圖片警告" "Gateway 評估了不同的 ClawHub 發行版本。請在安裝前再次檢閱此技能。" "開啟系統存取權限" @@ -916,6 +922,7 @@ "檢查" "讀取此手機的位置" "Skill 金鑰" + "將於 %1$sm %2$ss 後過期" "已安裝 %1$s。" "Gateways" "轉發模式" @@ -1213,6 +1220,7 @@ "... 另有 %1$s 個" "展開計畫檢查清單" "瀏覽器" + "已取消" "screen record" "執行待處理項目" "您裝置上的個人 AI" @@ -1335,6 +1343,7 @@ "提供者與模型" "節點" "%1$s " + "將於 %1$ss 後過期" "提示詞無法使用" "記錄" "請連線至 Gateway 以檢查 Skill Workshop 提案。" diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index df5259396d7b..3620edec4eb3 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -59,6 +59,7 @@ "Command argv JSON array" "Could not read that image. Choose a clear screenshot or image of the QR from openclaw qr." "Could not %1$s Skill Workshop proposal." + "Answered elsewhere" "Gateway recorded approval once." "status" "OpenClaw only checks location when your paired Gateway requests it. On the next Android screen, choose %1$s to allow checks while the app is in the background." @@ -129,6 +130,7 @@ "Paired Devices" "%1$s pending" "Device name" + "Submit" "Location" "e.g. America/New_York" "Session target" @@ -382,6 +384,7 @@ "No matching automations." "Phone can reach the Gateway" "Gateway" + "Expired" "Scheduled OpenClaw work from your gateway." "Sub-agent" "Waiting for device approval" @@ -411,6 +414,7 @@ "Issue" "Search registry metadata. The Gateway verifies trust again before any download." "Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access." + "Submitting…" "Account %1$s" "Suggest Task" "Search" @@ -584,6 +588,7 @@ "This will quarantine \"%1$s\" and refresh Skill Workshop state from the gateway." "Record voice note" "Queued" + "Answered" "Allow camera tools when requested." "Issues" "Voice Wake" @@ -702,6 +707,7 @@ "Session Settings" "Connect gateway to start" "Security notice" + "Other answer" "Dismiss shared-image warning" "The Gateway evaluated a different ClawHub release. Review the skill again before installing." "Open System Access" @@ -916,6 +922,7 @@ "Check" "Read this phone\'s location" "Skill Key" + "Expires in %1$sm %2$ss" "Installed %1$s." "Gateways" "Forwarding Mode" @@ -1213,6 +1220,7 @@ "... +%1$s more" "Expand plan checklist" "Browser" + "Cancelled" "screen record" "Run Pending" "Personal AI on your devices" @@ -1335,6 +1343,7 @@ "Providers & Models" "Node" "%1$s " + "Expires in %1$ss" "Prompt unavailable" "Logs" "Connect the gateway to inspect Skill Workshop proposals." diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 3aee4ac477c9..60662204029a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -291,6 +291,7 @@ class GatewayBootstrapAuthTest { listOf( "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt new file mode 100644 index 000000000000..4eda57b35b0d --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt @@ -0,0 +1,270 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.Question +import ai.openclaw.app.gateway.QuestionListResult +import ai.openclaw.app.gateway.QuestionOption +import ai.openclaw.app.gateway.QuestionRecord +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatQuestionTest { + private val question = + Question( + id = "meal", + header = "Meal", + question = "Choose dinner", + options = listOf(QuestionOption("Pizza"), QuestionOption("Tacos")), + multiSelect = true, + isOther = true, + ) + + @Test + fun multiSelectAnswersFollowDeclaredOrderAndIncludeOther() { + val draft = + ChatQuestionDraft() + .toggle(question, "Tacos") + .toggle(question, "Pizza") + .setOther(question, " Salad ") + + assertEquals(mapOf("meal" to listOf("Pizza", "Tacos", "Salad")), draft.answers(listOf(question))) + } + + @Test + fun statusDistinguishesLocalRemoteAndExpiry() { + val record = record(status = "pending", expiresAtMs = 2_000) + assertEquals(ChatQuestionStatus.Expired, ChatQuestionPrompt(record).status(nowMs = 2_000)) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + ChatQuestionPrompt(record.copy(status = "answered")).status(nowMs = 1_000), + ) + assertEquals( + ChatQuestionStatus.Answered, + ChatQuestionPrompt(record.copy(status = "answered"), answeredLocally = true).status(nowMs = 1_000), + ) + } + + @Test + fun terminalPromptRetentionMatchesGatewayGrace() { + val prompt = + ChatQuestionPrompt( + record = record(status = "answered"), + terminalObservedAtMs = 1_000, + ) + + assertTrue(prompt.shouldRetainAfterList(15_999)) + assertFalse(prompt.shouldRetainAfterList(16_000)) + } + + @Test + fun sessionFilterKeepsGlobalAndCurrentPrompts() { + val prompts = + listOf( + ChatQuestionPrompt(record(sessionKey = null)), + ChatQuestionPrompt(record(id = "current", sessionKey = "agent:main:main")), + ChatQuestionPrompt(record(id = "other", sessionKey = "agent:main:other")), + ChatQuestionPrompt(record(id = "foreign-main", sessionKey = "main", agentId = "other")), + ) + val visible = questionsForSession(prompts, "main", "agent:main:main", "main") + assertEquals(listOf("ask_123", "current"), visible.map { it.record.id }) + assertTrue(visible.all { it.status(1_000) == ChatQuestionStatus.Pending }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleQuestionListCannotOverwriteNewerEvent() = + runTest { + val listStarted = CompletableDeferred() + val listResponse = CompletableDeferred() + val json = Json { ignoreUnknownKeys = true } + var listCallCount = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "question.list") { + listCallCount += 1 + if (listCallCount == 1) { + listStarted.complete(Unit) + listResponse.await() + } else { + json.encodeToString(QuestionListResult(listOf(record(id = "ask_new")))) + } + } else { + "{}" + } + }, + ) + + controller.handleGatewayEvent("health", null) + runCurrent() + listStarted.await() + controller.handleGatewayEvent("question.requested", json.encodeToString(record(id = "ask_new"))) + listResponse.complete(json.encodeToString(QuestionListResult(listOf(record(id = "ask_old"))))) + advanceUntilIdle() + + assertEquals(listOf("ask_new"), controller.questions.value.map { it.record.id }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun pendingRefreshPreservesSubmissionLock() = + runTest { + val resolveStarted = CompletableDeferred() + val resolveResponse = CompletableDeferred() + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(listOf(pending.copy(createdAtMs = 2_000)))) + "question.resolve" -> { + resolveStarted.complete(Unit) + resolveResponse.await() + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.resolveQuestion(pending.id, mapOf("meal" to listOf("Pizza"))) + runCurrent() + resolveStarted.await() + controller.handleGatewayEvent("health", null) + runCurrent() + + assertEquals( + ChatQuestionStatus.Submitting, + controller.questions.value + .single() + .status(nowMs = 3_000), + ) + assertFalse( + controller.questions.value + .single() + .answeredLocally, + ) + resolveResponse.complete("{}") + advanceUntilIdle() + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun resolvedEventReconcilesAfterDiscardingOlderList() = + runTest { + val firstListStarted = CompletableDeferred() + val firstListResponse = CompletableDeferred() + val json = Json { ignoreUnknownKeys = true } + var listCallCount = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method != "question.list") { + "{}" + } else { + listCallCount += 1 + if (listCallCount == 1) { + firstListStarted.complete(Unit) + firstListResponse.await() + } else { + json.encodeToString(QuestionListResult(listOf(record(id = "ask_other")))) + } + } + }, + ) + + controller.handleGatewayEvent("health", null) + runCurrent() + firstListStarted.await() + controller.handleGatewayEvent( + "question.resolved", + """{"id":"ask_done","status":"answered"}""", + ) + runCurrent() + firstListResponse.complete(json.encodeToString(QuestionListResult(listOf(record(id = "ask_done"))))) + advanceUntilIdle() + + assertEquals(listOf("ask_other"), controller.questions.value.map { it.record.id }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun questionListRetainsRecentlyResolvedCard() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(id = "ask_done", expiresAtMs = Long.MAX_VALUE) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> json.encodeToString(QuestionListResult(emptyList())) }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.handleGatewayEvent( + "question.resolved", + """{"id":"ask_done","status":"answered"}""", + ) + runCurrent() + + assertEquals(listOf("ask_done"), controller.questions.value.map { it.record.id }) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single() + .status(), + ) + + advanceTimeBy(QUESTION_TERMINAL_RETENTION_MS) + runCurrent() + + assertTrue(controller.questions.value.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun locallyExpiredQuestionIsEvictedAfterTerminalGrace() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val controller = ChatController(scope = this, json = json, requestGateway = { _, _ -> "{}" }) + val pending = record(expiresAtMs = 1_000) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + advanceTimeBy(QUESTION_TERMINAL_RETENTION_MS + 1_001) + runCurrent() + + assertTrue(controller.questions.value.isEmpty()) + } + + private fun record( + id: String = "ask_123", + status: String = "pending", + expiresAtMs: Long = Long.MAX_VALUE, + sessionKey: String? = "agent:main:main", + agentId: String? = "main", + ) = QuestionRecord( + id = id, + questions = listOf(question), + agentId = agentId, + sessionKey = sessionKey, + createdAtMs = 1_000, + expiresAtMs = expiresAtMs, + status = status, + ) +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index 80478445d0f2..241ece47e78d 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -433,6 +433,7 @@ class ConnectionManagerTest { listOf( "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/apps/ios/Resources/Localizable.xcstrings b/apps/ios/Resources/Localizable.xcstrings index 1b3452444a86..4def5447c0e9 100644 --- a/apps/ios/Resources/Localizable.xcstrings +++ b/apps/ios/Resources/Localizable.xcstrings @@ -114105,6 +114105,142 @@ } } }, + "Other answer": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other answer" + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "其他答案" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "其他答案" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Outra resposta" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Andere Antwort" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Otra respuesta" + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "その他の回答" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기타 답변" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autre réponse" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "अन्य उत्तर" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إجابة أخرى" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Altra risposta" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diğer yanıt" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Інша відповідь" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Jawaban lain" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Inna odpowiedź" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำตอบอื่น" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Câu trả lời khác" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Ander antwoord" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "پاسخ دیگر" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Другой ответ" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Annat svar" + } + } + } + }, "Out-of-app approval alerts need notification permission.": { "localizations": { "en": { @@ -159665,6 +159801,278 @@ } } }, + "Submit": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Submit" + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "提交" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "提交" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Enviar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Absenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar" + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "送信" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제출" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सबमिट करें" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إرسال" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invia" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gönder" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надіслати" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Kirim" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Prześlij" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่ง" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Gửi" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Indienen" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "ارسال" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправить" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Skicka" + } + } + } + }, + "Submitting…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Submitting…" + } + }, + "zh-CN": { + "stringUnit": { + "state": "translated", + "value": "正在提交…" + } + }, + "zh-TW": { + "stringUnit": { + "state": "translated", + "value": "正在提交…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Enviando…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird gesendet…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviando…" + } + }, + "ja-JP": { + "stringUnit": { + "state": "translated", + "value": "送信中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제출 중…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoi…" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सबमिट किया जा रहा है…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الإرسال…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invio in corso…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gönderiliyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надсилання…" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Mengirim…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przesyłanie…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังส่ง…" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang gửi…" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Bezig met indienen…" + } + }, + "fa": { + "stringUnit": { + "state": "translated", + "value": "در حال ارسال…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправка…" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Skickar…" + } + } + } + }, "Summarize the current OpenClaw status and tell me what needs attention.": { "localizations": { "en": { diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 405cb349d5dd..91249bcd1957 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -499,6 +499,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: res))?.ok ?? true } + func listQuestions() async throws -> [QuestionRecord] { + let data = try await self.gateway.request(OpenClawChatGatewayRequests.questionList()) + return try JSONDecoder().decode(QuestionListResult.self, from: data).questions + } + + func resolveQuestion(id: String, answers: [String: [String]]) async throws { + _ = try await self.gateway.request(OpenClawChatGatewayRequests.resolveQuestion(id: id, answers: answers)) + } + func events() -> AsyncStream { AsyncStream { continuation in let task = Task { diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 146fa6bce999..116fac4fc3e2 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -4869,10 +4869,10 @@ extension NodeAppModel { if includeAdminScope { scopes.append("operator.admin") } - // Preserve reconnect compatibility for older paired operator tokens that were - // approved before iOS requested operator.approvals by default. + // Older paired tokens request a scope upgrade before interactive prompts can arrive. if includeApprovalScope { scopes.append("operator.approvals") + scopes.append("operator.questions") } return GatewayConnectOptions( role: "operator", diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index fadd426bfeb9..4eec9f46f2a3 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -321,11 +321,13 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn #expect(withoutApprovalScope.scopes.contains("operator.read")) #expect(withoutApprovalScope.scopes.contains("operator.write")) #expect(!withoutApprovalScope.scopes.contains("operator.approvals")) + #expect(!withoutApprovalScope.scopes.contains("operator.questions")) #expect(withoutApprovalScope.scopes.contains("operator.talk.secrets")) #expect(!withoutApprovalScope.scopesAreExplicit) #expect(withoutApprovalScope.caps == [OpenClawGatewayClientCapability.inlineWidgets]) #expect(withApprovalScope.scopes.contains("operator.approvals")) + #expect(withApprovalScope.scopes.contains("operator.questions")) #expect(withAdminScope.scopes.contains("operator.admin")) } diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index cb57ae5785fc..42b82507c0da 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -505,6 +505,16 @@ struct MacGatewayChatTransport: OpenClawChatTransport { try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) } + func listQuestions() async throws -> [QuestionRecord] { + let data = try await GatewayConnection.shared.request(OpenClawChatGatewayRequests.questionList()) + return try JSONDecoder().decode(QuestionListResult.self, from: data).questions + } + + func resolveQuestion(id: String, answers: [String: [String]]) async throws { + _ = try await GatewayConnection.shared.request( + OpenClawChatGatewayRequests.resolveQuestion(id: id, answers: answers)) + } + func waitForRunCompletion( runId rawRunId: String, timeoutMs: Int) async -> OpenClawChatRunObservation diff --git a/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift index 479c176d5d84..ab040a706a74 100644 --- a/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift +++ b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift @@ -3,5 +3,6 @@ let defaultOperatorConnectScopes: [String] = [ "operator.read", "operator.write", "operator.approvals", + "operator.questions", "operator.pairing", ] diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift index 486a939e0304..8cae4575cd9b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift @@ -139,6 +139,18 @@ public enum OpenClawChatGatewayPayloadCodec { as: OpenClawAgentEventPayload.self) else { return nil } return .agent(agent) + case "question.requested": + guard let payload = frame.payload, + let question = try? GatewayPayloadDecoding.decode(payload, as: QuestionRecord.self) + else { return nil } + return .questionRequested(question) + case "question.resolved": + guard let payload = frame.payload, + let resolved = try? GatewayPayloadDecoding.decode( + payload, + as: OpenClawQuestionResolvedEvent.self) + else { return nil } + return .questionResolved(resolved) default: return nil } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift index c8ab219bec8b..aa128fe4d2bb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift @@ -80,6 +80,24 @@ public enum OpenClawChatGatewayRequests { OpenClawChatGatewayRequest(method: "models.list", timeoutMs: self.defaultTimeoutMs) } + public static func questionList() -> OpenClawChatGatewayRequest { + OpenClawChatGatewayRequest(method: "question.list", timeoutMs: self.defaultTimeoutMs) + } + + public static func resolveQuestion( + id: String, + answers: [String: [String]]) -> OpenClawChatGatewayRequest + { + let values = answers.mapValues { AnyCodable(["answers": AnyCodable($0)]) } + return OpenClawChatGatewayRequest( + method: "question.resolve", + params: [ + "id": AnyCodable(id), + "answers": AnyCodable(["answers": AnyCodable(values)]), + ], + timeoutMs: self.mutationTimeoutMs) + } + public static func sessionsList( limit: Int?, search: String?, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift new file mode 100644 index 000000000000..eea749dcdfaf --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift @@ -0,0 +1,496 @@ +import Foundation +import Observation +import OpenClawKit +import OpenClawProtocol +import SwiftUI + +public enum OpenClawQuestionCardStatus: Sendable, Equatable { + case pending + case submitting + case answered + case answeredElsewhere + case expired + case cancelled +} + +@MainActor +@Observable +public final class OpenClawQuestionCardModel: Identifiable { + private static let terminalRetentionSeconds: TimeInterval = 15 + + public let id: String + public private(set) var record: QuestionRecord + public private(set) var isSubmitting = false + public private(set) var wasAnsweredLocally = false + public private(set) var errorText: String? + public private(set) var selectedOptions: [String: Set] = [:] + public private(set) var otherText: [String: String] = [:] + private var terminalObservedAt: Date? + + public init(record: QuestionRecord) { + self.id = record.id + self.record = record + self.terminalObservedAt = record.status == .pending ? nil : Date() + } + + @discardableResult + public func apply(record: QuestionRecord, at date: Date = Date()) -> Bool { + guard record.id == self.id, !Self.recordsMatch(self.record, record) else { return false } + self.record = record + self.isSubmitting = self.isSubmitting && record.status == .pending + self.terminalObservedAt = record.status == .pending ? nil : (self.terminalObservedAt ?? date) + return true + } + + public func status(at date: Date = Date()) -> OpenClawQuestionCardStatus { + switch self.record.status { + case .answered: + return self.wasAnsweredLocally ? .answered : .answeredElsewhere + case .cancelled: + return .cancelled + case .expired: + return .expired + case .pending: + if date.timeIntervalSince1970 * 1000 >= Double(self.record.expiresatms) { + return .expired + } + return self.isSubmitting ? .submitting : .pending + } + } + + public func remainingSeconds(at date: Date = Date()) -> Int { + max(0, Int(ceil(Double(self.record.expiresatms) / 1000 - date.timeIntervalSince1970))) + } + + public func toggleOption(questionID: String, label: String) { + guard let question = self.record.questions.first(where: { $0.id == questionID }), + question.options.contains(where: { $0.label == label }), + self.status() == .pending + else { return } + var selected = self.selectedOptions[questionID] ?? [] + if question.multiselect == true { + if selected.contains(label) { + selected.remove(label) + } else { + selected.insert(label) + } + } else { + selected = selected == [label] ? [] : [label] + if !selected.isEmpty { + self.otherText[questionID] = "" + } + } + self.selectedOptions[questionID] = selected + self.errorText = nil + } + + public func setOtherText(questionID: String, value: String) { + guard let question = self.record.questions.first(where: { $0.id == questionID }), + question.options.isEmpty || question.isother == true, + self.status() == .pending + else { return } + self.otherText[questionID] = value + if question.multiselect != true, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.selectedOptions[questionID] = [] + } + self.errorText = nil + } + + public var canSubmit: Bool { + self.status() == .pending && self.answers() != nil + } + + public func beginSubmission() -> [String: [String]]? { + guard let answers = self.answers(), self.status() == .pending else { return nil } + self.isSubmitting = true + self.errorText = nil + return answers + } + + public func markAnsweredLocally(at date: Date = Date()) { + self.wasAnsweredLocally = true + self.isSubmitting = false + self.terminalObservedAt = self.terminalObservedAt ?? date + self.record = QuestionRecord( + id: self.record.id, + questions: self.record.questions, + agentid: self.record.agentid, + sessionkey: self.record.sessionkey, + createdatms: self.record.createdatms, + expiresatms: self.record.expiresatms, + status: .answered, + answers: self.answers().map { answers in + QuestionAnswers(answers: answers.mapValues { values in + AnyCodable(["answers": values]) + }) + }, + resolvedby: self.record.resolvedby) + } + + public func apply(resolved: OpenClawQuestionResolvedEvent, at date: Date = Date()) { + guard resolved.id == self.id else { return } + self.isSubmitting = false + self.terminalObservedAt = self.terminalObservedAt ?? date + self.record = QuestionRecord( + id: self.record.id, + questions: self.record.questions, + agentid: self.record.agentid, + sessionkey: self.record.sessionkey, + createdatms: self.record.createdatms, + expiresatms: self.record.expiresatms, + status: resolved.status, + answers: resolved.answers, + resolvedby: self.record.resolvedby) + } + + public func failSubmission(_ message: String) { + self.isSubmitting = false + self.errorText = message + } + + func shouldRetainAfterList(at date: Date) -> Bool { + guard let terminalObservedAt else { return false } + return date.timeIntervalSince(terminalObservedAt) < Self.terminalRetentionSeconds + } + + func terminalRetentionDelay(at date: Date) -> TimeInterval? { + guard let terminalObservedAt else { return nil } + return max(0, Self.terminalRetentionSeconds - date.timeIntervalSince(terminalObservedAt)) + } + + func observeLocalExpiry(at date: Date) -> Bool { + guard self.record.status == .pending, self.terminalObservedAt == nil, + date.timeIntervalSince1970 * 1000 >= Double(self.record.expiresatms) + else { return false } + self.terminalObservedAt = date + return true + } + + func localExpiryDelay(at date: Date) -> TimeInterval? { + guard self.record.status == .pending, self.terminalObservedAt == nil else { return nil } + return max(0, Double(self.record.expiresatms) / 1000 - date.timeIntervalSince1970) + } + + private func answers() -> [String: [String]]? { + var result: [String: [String]] = [:] + for question in self.record.questions { + let selected = self.selectedOptions[question.id] ?? [] + var values = question.options.compactMap { selected.contains($0.label) ? $0.label : nil } + if let other = self.otherText[question.id]?.trimmingCharacters(in: .whitespacesAndNewlines), + !other.isEmpty + { + values.append(other) + } + guard !values.isEmpty else { return nil } + result[question.id] = values + } + return result + } + + private static func recordsMatch(_ lhs: QuestionRecord, _ rhs: QuestionRecord) -> Bool { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return (try? encoder.encode(lhs)) == (try? encoder.encode(rhs)) + } +} + +// periphery:ignore - Public SwiftUI component for package consumers; native apps embed it through the shared list. +public struct OpenClawQuestionCard: View { + @Bindable private var model: OpenClawQuestionCardModel + private let onSubmit: @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void + + // periphery:ignore - Public construction is part of the exported SwiftUI component API. + public init( + model: OpenClawQuestionCardModel, + onSubmit: @escaping @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void) + { + self.model = model + self.onSubmit = onSubmit + } + + // periphery:ignore - SwiftUI View witness must stay public for the exported component. + public var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + VStack(alignment: .leading, spacing: 14) { + ForEach(self.model.record.questions, id: \.id) { question in + self.questionSection(question, now: context.date) + } + self.footer(now: context.date) + } + .padding(16) + .background(OpenClawChatTheme.subtleCard, in: RoundedRectangle(cornerRadius: 16)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(.secondary.opacity(0.2))) + } + } + + private func questionSection(_ question: Question, now: Date) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(question.header.uppercased()) + .font(OpenClawChatTypography.captionSemiBold) + .foregroundStyle(OpenClawChatTheme.accent) + Text(question.question) + .font(OpenClawChatTypography.body) + ForEach(question.options, id: \.label) { option in + self.optionRow(question: question, option: option, now: now) + } + if question.options.isEmpty || question.isother == true { + TextField( + "Other answer", + text: Binding( + get: { self.model.otherText[question.id] ?? "" }, + set: { self.model.setOtherText(questionID: question.id, value: $0) }), + axis: .vertical) + .font(OpenClawChatTypography.body) + .textFieldStyle(.roundedBorder) + .disabled(self.model.status(at: now) != .pending) + .accessibilityLabel("Other answer") + } + } + } + + private func optionRow(question: Question, option: QuestionOption, now: Date) -> some View { + let selected = self.model.selectedOptions[question.id]?.contains(option.label) == true + return Button { + self.model.toggleOption(questionID: question.id, label: option.label) + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: selected + ? (question.multiselect == true ? "checkmark.square.fill" : "largecircle.fill.circle") + : (question.multiselect == true ? "square" : "circle")) + .foregroundStyle(selected ? OpenClawChatTheme.accent : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(option.label).font(OpenClawChatTypography.body) + if let description = option.description, !description.isEmpty { + Text(description) + .font(OpenClawChatTypography.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(self.model.status(at: now) != .pending) + .accessibilityLabel(option.label) + .accessibilityValue(selected ? "Selected" : "Not selected") + } + + @ViewBuilder + private func footer(now: Date) -> some View { + let status = self.model.status(at: now) + if status == .pending || status == .submitting { + HStack { + Text(self.countdownText(now: now)) + .font(OpenClawChatTypography.caption) + .foregroundStyle(.secondary) + Spacer() + Button(status == .submitting ? "Submitting…" : "Submit") { + Task { await self.onSubmit(self.model) } + } + .font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout)) + .buttonStyle(.borderedProminent) + .disabled(!self.model.canSubmit || status == .submitting) + } + if let errorText = self.model.errorText { + Text(errorText) + .font(OpenClawChatTypography.caption) + .foregroundStyle(OpenClawChatTheme.danger) + } + } else { + Label(self.terminalText(status), systemImage: self.terminalIcon(status)) + .font(OpenClawChatTypography.captionSemiBold) + .foregroundStyle(.secondary) + } + } + + private func countdownText(now: Date) -> String { + let seconds = self.model.remainingSeconds(at: now) + return seconds >= 60 ? "Expires in \(seconds / 60)m \(seconds % 60)s" : "Expires in \(seconds)s" + } + + private func terminalText(_ status: OpenClawQuestionCardStatus) -> String { + switch status { + case .answered: "Answered" + case .answeredElsewhere: "Answered elsewhere" + case .expired: "Expired" + case .cancelled: "Cancelled" + case .pending, .submitting: "Pending" + } + } + + private func terminalIcon(_ status: OpenClawQuestionCardStatus) -> String { + switch status { + case .answered, .answeredElsewhere: "checkmark.circle.fill" + case .expired: "clock.badge.xmark" + case .cancelled: "xmark.circle" + case .pending, .submitting: "clock" + } + } +} + +@MainActor +struct OpenClawQuestionCards: View { + let viewModel: OpenClawChatViewModel + + var body: some View { + ForEach(self.viewModel.visibleQuestionCards) { card in + OpenClawQuestionCard(model: card) { [weak viewModel = self.viewModel] model in + await viewModel?.submitQuestion(model) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +extension OpenClawChatViewModel { + public var visibleQuestionCards: [OpenClawQuestionCardModel] { + self.questionCards.filter { card in + guard let key = card.record.sessionkey else { return true } + return self.matchesCurrentSessionKey( + incoming: key, + agentId: card.record.agentid, + current: self.sessionKey) + } + } + + func refreshQuestions() async { + self.questionRefreshGeneration &+= 1 + let refreshGeneration = self.questionRefreshGeneration + let stateRevision = self.questionStateRevision + do { + let records = try await self.transport.listQuestions() + guard refreshGeneration == self.questionRefreshGeneration, + stateRevision == self.questionStateRevision + else { return } + let existing = Dictionary(uniqueKeysWithValues: self.questionCards.map { ($0.id, $0) }) + let listedIDs = Set(records.map(\.id)) + let now = Date() + let retainedTerminal = self.questionCards.filter { model in + !listedIDs.contains(model.id) && model.shouldRetainAfterList(at: now) + } + var changed = records.count + retainedTerminal.count != self.questionCards.count + self.questionCards = records.map { record in + if let model = existing[record.id] { + changed = model.apply(record: record) || changed + return model + } + changed = true + return OpenClawQuestionCardModel(record: record) + } + retainedTerminal + self.syncQuestionEvictions() + if changed { + self.questionStateRevision &+= 1 + self.markTimelineChanged() + } + } catch let error as GatewayResponseError where Self.questionListIsUnavailable(error) { + guard refreshGeneration == self.questionRefreshGeneration, + stateRevision == self.questionStateRevision, + !self.questionCards.isEmpty + else { return } + self.questionCards = [] + self.syncQuestionEvictions() + self.questionStateRevision &+= 1 + self.markTimelineChanged() + } catch { + // Question recovery is best-effort; chat bootstrap remains usable without this scope. + } + } + + private nonisolated static func questionListIsUnavailable(_ error: GatewayResponseError) -> Bool { + guard error.code == "INVALID_REQUEST" else { return false } + return error.message == "unknown method: question.list" || + error.message == "missing scope: operator.questions" + } + + func upsertQuestion(_ record: QuestionRecord) { + if let model = self.questionCards.first(where: { $0.id == record.id }) { + guard model.apply(record: record) else { return } + } else { + self.questionCards.append(OpenClawQuestionCardModel(record: record)) + } + self.questionStateRevision &+= 1 + self.syncQuestionEvictions() + self.markTimelineChanged() + } + + func resolveQuestionEvent(_ event: OpenClawQuestionResolvedEvent) { + self.questionCards.first(where: { $0.id == event.id })?.apply(resolved: event) + self.questionStateRevision &+= 1 + self.syncQuestionEvictions() + self.markTimelineChanged() + } + + func reconcileQuestionsAfterEvent() { + // Invalidate a list snapshot captured before this event, then fetch the + // authoritative set so other pending cards from that snapshot are not lost. + self.questionRefreshGeneration &+= 1 + Task { [weak self] in await self?.refreshQuestions() } + } + + func submitQuestion(_ model: OpenClawQuestionCardModel) async { + guard let answers = model.beginSubmission() else { return } + self.questionStateRevision &+= 1 + do { + try await self.transport.resolveQuestion(id: model.id, answers: answers) + model.markAnsweredLocally() + self.questionStateRevision &+= 1 + self.syncQuestionEvictions() + self.markTimelineChanged() + } catch { + model.failSubmission(error.localizedDescription) + self.questionStateRevision &+= 1 + } + } + + func evictQuestionIfTerminalGraceElapsed( + _ model: OpenClawQuestionCardModel, + at date: Date = Date()) + { + guard self.questionCards.first(where: { $0.id == model.id }) === model else { return } + if model.observeLocalExpiry(at: date) { + self.questionStateRevision &+= 1 + self.markTimelineChanged() + } + guard + !model.shouldRetainAfterList(at: date), + model.status(at: date) == .expired || model.record.status != .pending + else { + self.syncQuestionEvictions(at: date) + return + } + self.questionCards.removeAll { $0 === model } + self.questionEvictionTasks.removeValue(forKey: model.id)?.cancel() + self.questionEvictionDeadlines.removeValue(forKey: model.id) + self.questionStateRevision &+= 1 + self.markTimelineChanged() + } + + private func syncQuestionEvictions(at date: Date = Date()) { + let modelsByID = Dictionary(uniqueKeysWithValues: self.questionCards.map { ($0.id, $0) }) + let cancelledIDs = self.questionEvictionTasks.keys.filter { modelsByID[$0] == nil } + for id in cancelledIDs { + self.questionEvictionTasks.removeValue(forKey: id)?.cancel() + self.questionEvictionDeadlines.removeValue(forKey: id) + } + for model in self.questionCards { + guard let delay = model.terminalRetentionDelay(at: date) ?? model.localExpiryDelay(at: date) + else { continue } + let deadline = date.addingTimeInterval(delay) + if let scheduled = self.questionEvictionDeadlines[model.id], + abs(scheduled.timeIntervalSince(deadline)) < 0.01 + { + continue + } + self.questionEvictionTasks.removeValue(forKey: model.id)?.cancel() + self.questionEvictionDeadlines[model.id] = deadline + self.questionEvictionTasks[model.id] = Task { [weak self, weak model] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self, let model else { return } + self.questionEvictionTasks.removeValue(forKey: model.id) + self.questionEvictionDeadlines.removeValue(forKey: model.id) + self.evictQuestionIfTerminalGraceElapsed(model) + } + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift index 8a493b089755..ac5e2d716ef0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawProtocol public enum OpenClawChatTransportEvent: Sendable { case health(ok: Bool) @@ -7,9 +8,24 @@ public enum OpenClawChatTransportEvent: Sendable { case chat(OpenClawChatEventPayload) case sessionMessage(OpenClawSessionMessageEventPayload) case agent(OpenClawAgentEventPayload) + case questionRequested(QuestionRecord) + case questionResolved(OpenClawQuestionResolvedEvent) case seqGap } +public struct OpenClawQuestionResolvedEvent: Codable, Sendable { + public let id: String + public let status: QuestionStatus + public let answers: QuestionAnswers? + + // periphery:ignore - Package consumers construct transport events; native apps decode them. + public init(id: String, status: QuestionStatus, answers: QuestionAnswers? = nil) { + self.id = id + self.status = status + self.answers = answers + } +} + public struct OpenClawChatSessionsChangedEvent: Codable, Sendable, Equatable { public let sessionKey: String? public let agentId: String? @@ -319,6 +335,8 @@ public protocol OpenClawChatTransport: Sendable { func acquireSessionSettingsRouteLease() async -> OpenClawChatSessionSettingsRouteLease? func requestHealth(timeoutMs: Int) async throws -> Bool + func listQuestions() async throws -> [QuestionRecord] + func resolveQuestion(id: String, answers: [String: [String]]) async throws func waitForRunCompletion(runId: String, timeoutMs: Int) async -> OpenClawChatRunObservation func events() -> AsyncStream func resolveInlineWidgetResource( @@ -332,6 +350,17 @@ public protocol OpenClawChatTransport: Sendable { } extension OpenClawChatTransport { + public func listQuestions() async throws -> [QuestionRecord] { + [] + } + + public func resolveQuestion(id _: String, answers _: [String: [String]]) async throws { + throw NSError( + domain: "OpenClawChatTransport", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "question.resolve not supported by this transport"]) + } + public func requestFullMessage(sessionKey _: String, messageID _: String) async throws -> OpenClawChatMessage? { nil } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index e740afebe13a..e1f62bef0828 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -409,6 +409,8 @@ public struct OpenClawChatView: View { self.messageRow(for: msg, contextWindowTokens: contextWindowTokens) } + OpenClawQuestionCards(viewModel: self.viewModel) + if self.viewModel.hasBlockingRunActivity, !self.hasVisibleStreamingAssistantText { ChatTypingIndicatorBubble( style: self.style, @@ -688,7 +690,8 @@ public struct OpenClawChatView: View { private var hasVisibleTransientContent: Bool { self.viewModel.hasBlockingRunActivity || (self.displayOptions.contains(.toolActivity) && !self.viewModel.pendingToolCalls.isEmpty) || - self.hasVisibleStreamingAssistantText + self.hasVisibleStreamingAssistantText || + !self.viewModel.visibleQuestionCards.isEmpty } @ViewBuilder diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift index e80f15f89b1f..65cf6899bca7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift @@ -24,7 +24,11 @@ extension OpenClawChatViewModel { func handleTransportEvent(_ evt: OpenClawChatTransportEvent) { switch evt { case let .health(ok): + let reconnected = ok && !self.healthOK applyTransportHealth(ok) + if reconnected { + Task { [weak self] in await self?.refreshQuestions() } + } case .tick: let context = self.currentSessionSnapshot() Task { await self.pollHealthIfNeeded(force: false, sessionSnapshot: context) } @@ -38,6 +42,12 @@ extension OpenClawChatViewModel { self.handleSessionMessageEvent(message) case let .agent(agent): self.handleAgentEvent(agent) + case let .questionRequested(question): + self.upsertQuestion(question) + self.reconcileQuestionsAfterEvent() + case let .questionResolved(resolved): + self.resolveQuestionEvent(resolved) + self.reconcileQuestionsAfterEvent() case .seqGap: self.errorText = nil self.invalidateHistorySnapshots() @@ -47,6 +57,9 @@ extension OpenClawChatViewModel { self.updateStreamingAssistantText(nil) self.clearPlan() let context = self.beginHistoryRequest() + // Question refresh is best-effort and must not delay transcript + // recovery behind a slow gateway round trip. + Task { await self.refreshQuestions() } Task { await self.refreshHistoryAfterRun(historyRequest: context) await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 81be35260f13..1b099271f134 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -88,6 +88,11 @@ public final class OpenClawChatViewModel { } public private(set) var pendingRunCount: Int = 0 + public internal(set) var questionCards: [OpenClawQuestionCardModel] = [] + var questionRefreshGeneration: UInt64 = 0 + var questionStateRevision: UInt64 = 0 + var questionEvictionTasks: [String: Task] = [:] + var questionEvictionDeadlines: [String: Date] = [:] var hasActiveSessionRunWithoutChatSnapshot = false public private(set) var sessionKey: String { @@ -483,12 +488,15 @@ public final class OpenClawChatViewModel { } } - deinit { + isolated deinit { self.eventTask?.cancel() self.bootstrapTask?.cancel() self.outboxRetryTask?.cancel() self.outboxChangesTask?.cancel() self.activeSessionRunIndicatorTimeoutTask?.cancel() + for (_, task) in self.questionEvictionTasks { + task.cancel() + } for (_, task) in self.pendingRunOwnerTasks { task.cancel() } @@ -875,6 +883,8 @@ extension OpenClawChatViewModel { await self.syncActiveSessionSubscription(startingWith: context.session.key) guard self.isCurrentBootstrap(context) else { return } + Task { [weak self] in await self?.refreshQuestions() } + let payload = try await transport.requestHistory(sessionKey: context.session.key) guard self.isCurrentBootstrap(context) else { return } _ = self.applyHistoryPayload( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index d3db8a91b9cf..26f69bb939ed 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -801,6 +801,7 @@ extension GatewayChannelActor { let allowedOperatorScopes: Set = [ "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift index f08fa106173a..40da9a188fc0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift @@ -52,6 +52,7 @@ extension GatewayChannelActor { "operator.read", "operator.write", "operator.approvals", + "operator.questions", "operator.pairing", ] diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift index b7643e52cc13..ea68269cba19 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift @@ -195,6 +195,20 @@ struct ChatGatewayRequestTests { #expect(String(decoding: encoded, as: UTF8.self).contains("a.png")) } + @Test func `question resolve request preserves nested answer contract`() throws { + let request = OpenClawChatGatewayRequests.resolveQuestion( + id: "ask_123", + answers: ["meal": ["Pizza", "Salad"]]) + + #expect(request.method == "question.resolve") + let data = try JSONEncoder().encode(request.params) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let answers = try #require(object["answers"] as? [String: Any]) + let values = try #require(answers["answers"] as? [String: Any]) + let meal = try #require(values["meal"] as? [String: Any]) + #expect(meal["answers"] as? [String] == ["Pizza", "Salad"]) + } + @Test func `long running requests share exact gateway timeout margins`() { #expect(OpenClawChatGatewayRequests.agentWait(runID: "run-1", timeoutMs: 1).timeoutMs == 5001) #expect(OpenClawChatGatewayRequests.agentWait(runID: "run-1", timeoutMs: 30000).timeoutMs == 35000) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatQuestionCardTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatQuestionCardTests.swift new file mode 100644 index 000000000000..938f31f438d3 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatQuestionCardTests.swift @@ -0,0 +1,114 @@ +import Foundation +import OpenClawProtocol +import Testing +@testable import OpenClawChatUI + +@MainActor +private func questionRecord( + multiSelect: Bool = false, + isOther: Bool = true, + createdAtMs: Int = 1_000_000, + expiresAtMs: Int = 4_000_000_000_000, + status: QuestionStatus = .pending) -> QuestionRecord +{ + QuestionRecord( + id: "ask_123", + questions: [ + Question( + id: "meal", + header: "Meal", + question: "Choose dinner", + options: [ + QuestionOption(label: "Pizza", description: "Fast"), + QuestionOption(label: "Tacos"), + ], + multiselect: multiSelect, + isother: isOther), + ], + agentid: "main", + sessionkey: "agent:main:main", + createdatms: createdAtMs, + expiresatms: expiresAtMs, + status: status) +} + +@MainActor +@Test func `question card single select and other are exclusive`() { + let model = OpenClawQuestionCardModel(record: questionRecord()) + model.toggleOption(questionID: "meal", label: "Pizza") + #expect(model.beginSubmission() == ["meal": ["Pizza"]]) + model.failSubmission("retry") + + model.setOtherText(questionID: "meal", value: " Salad ") + #expect(model.selectedOptions["meal"]?.isEmpty == true) + #expect(model.beginSubmission() == ["meal": ["Salad"]]) +} + +@MainActor +@Test func `question card multi select uses declared option order`() { + let model = OpenClawQuestionCardModel(record: questionRecord(multiSelect: true)) + model.toggleOption(questionID: "meal", label: "Tacos") + model.toggleOption(questionID: "meal", label: "Pizza") + #expect(model.beginSubmission() == ["meal": ["Pizza", "Tacos"]]) +} + +@MainActor +@Test func `question card maps expiry and answer origin`() { + let now = Date(timeIntervalSince1970: 1500) + let expired = OpenClawQuestionCardModel(record: questionRecord(expiresAtMs: 1_499_000)) + #expect(expired.status(at: now) == .expired) + #expect(expired.remainingSeconds(at: now) == 0) + + let remote = OpenClawQuestionCardModel(record: questionRecord()) + remote.apply(resolved: OpenClawQuestionResolvedEvent(id: remote.id, status: .answered)) + #expect(remote.status(at: Date(timeIntervalSince1970: 1500)) == .answeredElsewhere) + + let local = OpenClawQuestionCardModel(record: questionRecord()) + local.markAnsweredLocally() + local.apply(resolved: OpenClawQuestionResolvedEvent(id: local.id, status: .answered)) + #expect(local.status(at: Date(timeIntervalSince1970: 1500)) == .answered) +} + +@MainActor +@Test func `question card pending refresh preserves submission`() { + let model = OpenClawQuestionCardModel(record: questionRecord(expiresAtMs: Int.max)) + model.toggleOption(questionID: "meal", label: "Pizza") + #expect(model.beginSubmission() != nil) + + #expect(model.apply(record: questionRecord(createdAtMs: 2_000_000, expiresAtMs: Int.max))) + #expect(model.status(at: Date(timeIntervalSince1970: 1500)) == .submitting) + + #expect(model.apply(record: questionRecord(createdAtMs: 2_000_000, expiresAtMs: Int.max, status: .answered))) + #expect(model.status(at: Date(timeIntervalSince1970: 1500)) == .answeredElsewhere) +} + +@MainActor +@Test func `question card retains terminal feedback for gateway grace`() { + let observedAt = Date(timeIntervalSince1970: 1500) + let model = OpenClawQuestionCardModel(record: questionRecord()) + model.apply(resolved: .init(id: model.id, status: .answered), at: observedAt) + + #expect(model.shouldRetainAfterList(at: observedAt.addingTimeInterval(14))) + #expect(!model.shouldRetainAfterList(at: observedAt.addingTimeInterval(15))) +} + +@MainActor +@Test func `question card locally expired state enters terminal retention`() { + let expiresAt = Date(timeIntervalSince1970: 1500) + let model = OpenClawQuestionCardModel(record: questionRecord(expiresAtMs: 1_500_000)) + + #expect(model.observeLocalExpiry(at: expiresAt)) + #expect(model.shouldRetainAfterList(at: expiresAt.addingTimeInterval(14))) + #expect(!model.shouldRetainAfterList(at: expiresAt.addingTimeInterval(15))) +} + +@MainActor +@Test func `question card stores local answers in gateway record shape`() throws { + let model = OpenClawQuestionCardModel(record: questionRecord()) + model.toggleOption(questionID: "meal", label: "Pizza") + model.markAnsweredLocally() + + let data = try JSONEncoder().encode(model.record.answers) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("\"meal\":{\"answers\":[\"Pizza\"]}")) +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index dad32f82add5..ebcb755b9364 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -1,5 +1,6 @@ import Foundation import OpenClawKit +import OpenClawProtocol import Testing @testable import OpenClawChatUI @@ -612,6 +613,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor private let waitForRunCompletionHook: (@Sendable (String, Int) async -> OpenClawChatRunObservation)? private let acquireSessionSettingsRouteLeaseHook: (@Sendable () async -> Void)? + private let listQuestionsHook: (@Sendable () async throws -> [QuestionRecord])? private let healthResponses: [Bool] private let stream: AsyncStream @@ -643,6 +645,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor sendMessageStatus: String = "ok", waitForRunCompletionHook: (@Sendable (String, Int) async -> OpenClawChatRunObservation)? = nil, acquireSessionSettingsRouteLeaseHook: (@Sendable () async -> Void)? = nil, + listQuestionsHook: (@Sendable () async throws -> [QuestionRecord])? = nil, healthResponses: [Bool] = [true]) { self.historyResponses = historyResponses @@ -668,6 +671,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor self.sendMessageStatus = sendMessageStatus self.waitForRunCompletionHook = waitForRunCompletionHook self.acquireSessionSettingsRouteLeaseHook = acquireSessionSettingsRouteLeaseHook + self.listQuestionsHook = listQuestionsHook self.healthResponses = healthResponses var cont: AsyncStream.Continuation! self.stream = AsyncStream { c in @@ -955,6 +959,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor return self.healthResponses.last ?? true } + func listQuestions() async throws -> [QuestionRecord] { + try await self.listQuestionsHook?() ?? [] + } + func waitForRunCompletion( runId: String, timeoutMs: Int) async -> OpenClawChatRunObservation @@ -1196,8 +1204,210 @@ extension TestChatTransportState { } } +private actor QuestionListGate { + private var continuation: CheckedContinuation<[QuestionRecord], Never>? + + var isWaiting: Bool { + self.continuation != nil + } + + func wait() async -> [QuestionRecord] { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume(with records: [QuestionRecord]) { + self.continuation?.resume(returning: records) + self.continuation = nil + } +} + +private actor QuestionListEventRace { + private var firstContinuation: CheckedContinuation<[QuestionRecord], Never>? + private var callCount = 0 + private let currentRecords: [QuestionRecord] + + init(currentRecords: [QuestionRecord]) { + self.currentRecords = currentRecords + } + + var firstIsWaiting: Bool { + self.firstContinuation != nil + } + + var calls: Int { + self.callCount + } + + func request() async -> [QuestionRecord] { + self.callCount += 1 + if self.callCount == 1 { + return await withCheckedContinuation { continuation in + self.firstContinuation = continuation + } + } + return self.currentRecords + } + + func resumeFirst(with records: [QuestionRecord]) { + self.firstContinuation?.resume(returning: records) + self.firstContinuation = nil + } +} + +private func chatQuestionRecord( + id: String, + status: QuestionStatus = .pending, + expiresAtMs: Int = 4_000_000_000_000) -> QuestionRecord +{ + QuestionRecord( + id: id, + questions: [ + Question( + id: "choice", + header: "Choice", + question: "Choose", + options: [QuestionOption(label: "One"), QuestionOption(label: "Two")]), + ], + agentid: "main", + sessionkey: "main", + createdatms: 1, + expiresatms: expiresAtMs, + status: status) +} + @Suite(.serialized) struct ChatViewModelTests { + @Test @MainActor func `locally expired question is evicted after terminal grace`() { + let viewModel = OpenClawChatViewModel( + sessionKey: "main", + transport: TestChatTransport(historyResponses: [])) + let expiresAt = Date(timeIntervalSince1970: 1500) + viewModel.upsertQuestion(chatQuestionRecord(id: "ask_local", expiresAtMs: 1_500_000)) + let model = viewModel.questionCards[0] + + viewModel.evictQuestionIfTerminalGraceElapsed(model, at: expiresAt) + #expect(viewModel.questionCards.map(\.id) == ["ask_local"]) + + viewModel.evictQuestionIfTerminalGraceElapsed( + model, + at: expiresAt.addingTimeInterval(15)) + #expect(viewModel.questionCards.isEmpty) + } + + @Test @MainActor func `stale question list cannot overwrite a newer event`() async throws { + let gate = QuestionListGate() + let transport = TestChatTransport( + historyResponses: [], + listQuestionsHook: { await gate.wait() }) + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + let refresh = Task { await viewModel.refreshQuestions() } + try await waitUntil("question list request") { await gate.isWaiting } + + viewModel.upsertQuestion(chatQuestionRecord(id: "ask_new")) + await gate.resume(with: [chatQuestionRecord(id: "ask_old")]) + await refresh.value + + #expect(viewModel.questionCards.map(\.id) == ["ask_new"]) + } + + @Test @MainActor func `definitive question list rejection clears stale cards`() async { + let transport = TestChatTransport( + historyResponses: [], + listQuestionsHook: { + throw GatewayResponseError( + method: "question.list", + code: "INVALID_REQUEST", + message: "unknown method: question.list", + details: nil) + }) + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + viewModel.upsertQuestion(chatQuestionRecord(id: "ask_stale")) + + await viewModel.refreshQuestions() + + #expect(viewModel.questionCards.isEmpty) + } + + @Test @MainActor func `transient question list rejection preserves event cards`() async { + let transport = TestChatTransport( + historyResponses: [], + listQuestionsHook: { + throw GatewayResponseError( + method: "question.list", + code: "UNAVAILABLE", + message: "try again", + details: nil) + }) + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + viewModel.upsertQuestion(chatQuestionRecord(id: "ask_live")) + + await viewModel.refreshQuestions() + + #expect(viewModel.questionCards.map(\.id) == ["ask_live"]) + } + + @Test @MainActor func `question recovery does not block bootstrap history`() async throws { + let questionGate = QuestionListGate() + let historyCalls = AsyncCounter() + let transport = TestChatTransport( + historyResponses: [historyPayload()], + requestHistoryHook: { _ in _ = await historyCalls.increment() }, + listQuestionsHook: { await questionGate.wait() }) + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + + viewModel.load() + try await waitUntil("question recovery request") { await questionGate.isWaiting } + try await waitUntil("history during question recovery") { await historyCalls.current() == 1 } + await questionGate.resume(with: []) + } + + @Test @MainActor func `resolved event reconciles after discarding older question list`() async throws { + let race = QuestionListEventRace(currentRecords: [chatQuestionRecord(id: "ask_other")]) + let transport = TestChatTransport( + historyResponses: [], + listQuestionsHook: { await race.request() }) + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + let initialRefresh = Task { await viewModel.refreshQuestions() } + try await waitUntil("first question list request") { await race.firstIsWaiting } + + viewModel.handleTransportEvent(.questionResolved(.init(id: "ask_done", status: .answered))) + try await waitUntil("question event reconciliation") { await race.calls == 2 } + await race.resumeFirst(with: [chatQuestionRecord(id: "ask_done")]) + await initialRefresh.value + for _ in 0..<100 where viewModel.questionCards.map(\.id) != ["ask_other"] { + await Task.yield() + } + #expect(viewModel.questionCards.map(\.id) == ["ask_other"]) + } + + @Test @MainActor func `question list retains recently resolved card`() async { + let transport = TestChatTransport( + historyResponses: [], + listQuestionsHook: { [] }) + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + viewModel.upsertQuestion(chatQuestionRecord(id: "ask_done")) + viewModel.resolveQuestionEvent(.init(id: "ask_done", status: .answered)) + + await viewModel.refreshQuestions() + + #expect(viewModel.questionCards.map(\.id) == ["ask_done"]) + #expect(viewModel.questionCards.first?.status() == .answeredElsewhere) + } + + @Test @MainActor func `question card is evicted after terminal grace`() { + let transport = TestChatTransport(historyResponses: []) + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + viewModel.upsertQuestion(chatQuestionRecord(id: "ask_done")) + viewModel.resolveQuestionEvent(.init(id: "ask_done", status: .answered)) + let model = viewModel.questionCards[0] + + viewModel.evictQuestionIfTerminalGraceElapsed(model, at: Date().addingTimeInterval(16)) + + #expect(viewModel.questionCards.isEmpty) + } + @Test func `context usage fraction validates freshness and token bounds`() { func fraction(total: Int?, fresh: Bool? = true, context: Int?) -> Double? { OpenClawChatViewModel.chatContextUsageFraction( diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index bb69373245b5..f2a20feef6ea 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -13,7 +13,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c 30452bae2a689fb75dcb6dba9ef11e5b95f9cbe0c62eb190a0fcb82d0a19ae52 module/agent-core 74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness e09226afd443cee02ed648f032f53bfeb60585617bb523a649995764d3c38da9 module/agent-harness-exec-review-runtime -6131781d5c77140711114835b64e087cc81eb87fde6644dd6b2b1f3dafb28d32 module/agent-harness-runtime +b4360e05090c276641edd472d29e504828b59de335e9d5711d67053c7d0f7bd5 module/agent-harness-runtime ec22d7a039fb58d0b8343ad149322960d3d8ca58b3f4c70f2fa8a099f8186d0c module/agent-harness-task-runtime 5f63bf587bf3547d59d0dc5d0dc2fee54745aa6edaab4aa3ae700dba03443edb module/agent-harness-tool-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload @@ -104,7 +104,7 @@ d4ade9c9530585638e57602fcbe3ba1caa21a8d1ce88d87019d2d7048e3988b4 module/core e6fc74e18435cd4639801d9bd1f224154d1cd9405cdb3ba91a24b67c0792e9d2 module/dangerous-name-runtime 4af19d59c2f18674e7d7f7dc1b358b644dc707e6bd601dc47168bd9e4a669940 module/dedupe-runtime feb743a5f25297abc7bb47d22ef060e0ed54b0cff69ac50885681dfade970f8e module/delivery-queue-runtime -9f87be19881a070787f650740d84551cc5fe0ffc2b073330cf4c55eb8e31c991 module/device-bootstrap +7edf89b42d01805f8c1c5c7d9909ff82c0a2e9a5051a8001f84fa05fb8e0a751 module/device-bootstrap 6215d3af5923bf5a616d73062534968b69f448e3e30adc64ae9caebdd1a46d71 module/diagnostic-runtime 9885951a437128dd3ebea5a54dcd59e6e9f4a1ba78b166c2c4aa0a286f108fe5 module/direct-dm 9cdf7fbc6551e0373d6ab007128a266b200b5c8916fc4f73ffa77192cb0c3e9b module/direct-dm-access @@ -236,7 +236,7 @@ fffda485ad0492168b0b58671a5fe232de028c64cd1c0e36ab710ba7d58bf9d3 module/provide 69e205f87efbee176778ef5810c104dc0528e2545cafcf31b6cc8ea360e06020 module/provider-zai-endpoint 56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture 08f9f6abbe7f01fab7f96d611058f0de55996026a0e90cfd014fd2fdbe62ba0a module/qa-runner-runtime -99f8046623351bb03bf54f45691d437604ba27c4418d05b6c7d4da91f30a6179 module/question-gateway-runtime +aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/question-gateway-runtime 5e6ac90d11f2f6ed764f5c53ef98110551ff4beaa0d5dcc343a3e7d97e41844d module/realtime-bootstrap-context 6f551c1da49e6652f4af775798f45d3f3d5949c884c2e1967fc6254d34e9867e module/realtime-transcription 5f63f3b22b2a88f05da90e99b0c474dd450b6ea3e5d4145cbf50517bff8466cf module/realtime-voice diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index d160f3a413e8..31946fe2f998 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -641,6 +641,13 @@ All actions are enabled by default; use `channels.imessage.actions` to turn indi - Legacy text-style tapbacks (`Liked "…"` plain text from very old Apple clients) cannot resolve approvals because they carry no message GUID; reaction resolution requires the structured tapback metadata that current macOS / iOS clients emit. + + + For an `ask_user` prompt with one non-secret, single-select question and one to four options, OpenClaw adds numbered emoji choices. React to the delivered prompt with the matching number to answer it. The reaction must carry the stable GUID of the bot-authored message; OpenClaw then maps the number to the canonical option through the Gateway. Stale or duplicate taps are ignored. + + Multi-question, multi-select, and free-text prompts remain text-reply-only. Question reactions follow normal iMessage DM/group admission rules. They are recognized even when general `reactionNotifications` is `"off"`, without turning unrelated reactions into agent events. + + ## Config writes diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 2e903846e8bf..fe8a0f97a99f 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -339,6 +339,10 @@ Signal exec and plugin approval prompts use the top-level `approvals.exec` and ` Approval reaction resolution requires explicit Signal approvers from `channels.signal.allowFrom`, `channels.signal.defaultTo`, or the matching account-level fields. Direct same-chat exec approval prompts can still suppress the duplicate local `/approve` fallback without explicit approvers; no-approver group approvals keep the local fallback visible. +## Question reactions + +For an `ask_user` prompt with one non-secret, single-select question and one to four options, Signal shows `1️⃣` through `4️⃣` beside the option labels. React to the delivered prompt with the matching number to answer it. OpenClaw verifies the reaction targets the bot-authored message, then maps the number to the canonical option through the Gateway. Stale or duplicate taps are ignored. Multi-question, multi-select, and free-text prompts remain text-reply-only; normal Signal DM/group admission rules authorize the sender. + ## Delivery targets (CLI/cron) - DMs: `signal:+15551234567` (or plain E.164). diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 6c17bd31fd40..257e15e219c7 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -228,6 +228,10 @@ WhatsApp can render exec and plugin approval prompts as `👍`/`👎` reactions, WhatsApp approval reactions require explicit approvers in `allowFrom` (or `"*"`). `defaultTo` sets ordinary default message targets, not an approver list. Manual `/approve` commands still pass the normal WhatsApp sender-authorization path before approval resolution. +## Question reactions + +For an `ask_user` prompt with one non-secret, single-select question and one to four options, WhatsApp shows `1️⃣` through `4️⃣` beside the option labels. React to the delivered prompt with the matching number to answer it. OpenClaw maps the number to the canonical option through the Gateway; stale or duplicate taps are ignored. Multi-question, multi-select, and free-text prompts remain text-reply-only. Normal WhatsApp DM/group admission rules authorize the reacting sender. + ## Plugin hooks and privacy Inbound WhatsApp messages can carry personal content, phone numbers, group identifiers, sender names, and session correlation fields. WhatsApp does not broadcast inbound `message_received` hook payloads to plugins unless you opt in: diff --git a/docs/docs_map.md b/docs/docs_map.md index f1469775cf43..a56352777a95 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -859,6 +859,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Lifecycle status reactions - H2: Reactions (message tool) - H2: Approval reactions + - H2: Question reactions - H2: Delivery targets (CLI/cron) - H2: Aliases - H2: Troubleshooting @@ -1053,6 +1054,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Runtime model - H2: Call the current requester with MeowCaller (experimental) - H2: Approval prompts + - H2: Question reactions - H2: Plugin hooks and privacy - H2: Access control and activation - H2: Configured ACP bindings @@ -5126,6 +5128,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: 8. Voice + expanded Android command surface - H3: 9. Workspace files (read-only) - H2: Review command approvals + - H2: Answer agent questions - H2: Assistant entrypoints - H2: Notification forwarding - H2: Related @@ -5185,6 +5188,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Quick start (pair + connect) - H2: Health summaries - H2: Review command approvals + - H2: Answer agent questions - H2: Optional direct Apple Watch node - H2: Relay-backed push for official builds - H2: Background alive beacons diff --git a/docs/platforms/android.md b/docs/platforms/android.md index a6a9ba659c32..f3f6a6df87b2 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -360,6 +360,15 @@ Gateways that predate the unified approval methods fall back to the shipped exec-specific methods. Pending review still works, but retained terminal state and the richer cross-surface result require an updated Gateway. +## Answer agent questions + +Chat shows pending Gateway questions as native cards for operator connections +with `operator.questions` (or `operator.admin`). Cards support single- and +multi-select options, option descriptions, free-text **Other** answers, and an +expiry countdown. Reconnects reload pending questions from the Gateway. A card +locks when this device answers it, another surface answers it first, or the +question expires or is cancelled. + ## Assistant entrypoints Android supports launching OpenClaw from the system assistant trigger (Google Assistant). Holding the home button (or another `ACTION_ASSIST` trigger) opens the app; saying "Hey Google, ask OpenClaw ``" matches the app's declared App Actions query pattern and hands the prompt into the chat composer without auto-sending it. diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 4fea78ac3824..57dba14c9692 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -138,6 +138,15 @@ unified approval methods fall back to the shipped exec-specific methods; retained terminal state and richer cross-surface results require an updated Gateway. +## Answer agent questions + +Chat shows pending Gateway questions as native cards for operator connections +with `operator.questions` (or `operator.admin`). Cards support single- and +multi-select options, option descriptions, free-text **Other** answers, and an +expiry countdown. Reconnects reload pending questions from the Gateway. A card +locks when this device answers it, another surface answers it first, or the +question expires or is cancelled. + ## Optional direct Apple Watch node Direct mode gives the watch its own signed node identity and Gateway connection. diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 65f309866c83..9bfcabf12611 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -11,7 +11,7 @@ The full chat window is a native split view: - **Sessions sidebar**: searchable session list with pinned and recent sections. Spawned child sessions nest beneath their parent when the Gateway provides hierarchy metadata; collapsed parents summarize running, failed, and unread descendants. Context menus support rename, pin, fork, read/unread, archive/restore, copy session key, and delete. A toolbar button (or Cmd-N) creates a real new session via `sessions.create`. - **Window toolbar**: context-usage ring (tokens and session cost, with a compact action), model controls, and a session actions menu. Models are grouped by provider with the default provider first, while pinned and recent models remain at the top. The controls can inherit or override the model's thinking level, choose tool-call verbosity, and toggle Fast responses. The menu can rename or fork the current session and update its pin, read, or archive state. **Sessions…** (Shift-Cmd-S) opens the Active/Archived manager for gateway search, rename, pin, archive, and restore. Separate menu checkmarks show or hide assistant reasoning and tool activity; both are on by default and remembered across launches. -- **Transcript and composer**: assistant messages render as plain text with an avatar, user messages as accent bubbles. Empty chats offer desktop starter prompts. Typing `/` opens slash-command autocomplete backed by `commands.list`, with arrow/Tab/Return/Escape keyboard navigation. Right-click a message to copy its visible Markdown without hidden reasoning. Truncated assistant messages also offer **Open Full Message**, which loads a selectable Markdown reader. Use **Listen** for gateway TTS with a local speech fallback. +- **Transcript and composer**: assistant messages render as plain text with an avatar, user messages as accent bubbles. Pending agent questions render as native cards with single- or multi-select options, free-text **Other** answers, expiry countdowns, and shared terminal state. Empty chats offer desktop starter prompts. Typing `/` opens slash-command autocomplete backed by `commands.list`, with arrow/Tab/Return/Escape keyboard navigation. Right-click a message to copy its visible Markdown without hidden reasoning. Truncated assistant messages also offer **Open Full Message**, which loads a selectable Markdown reader. Use **Listen** for gateway TTS with a local speech fallback. - **Voice controls**: the composer can start or stop the existing macOS Talk Mode without replacing its menu-bar overlay. While Talk Mode is active, the composer shows its listening/thinking/speaking state, live audio activity, and an expandable rolling transcript. Right-click the Talk button to choose **System Default** or a connected microphone; this is the same microphone selection used by Voice Wake and push-to-talk. If a selected microphone disconnects, the active Talk session falls back to the system default and tries the selection again the next time Talk Mode starts. A separate microphone action records a voice note when Talk Mode does not own audio capture. The anchored compact chat panel from the menu bar keeps the compact single-column layout with the same model, thinking, verbosity, and Fast controls inline, plus starter prompts, Talk Mode, voice notes, and Listen. Assistant reasoning and tool activity remain hidden in this compact surface. @@ -46,7 +46,7 @@ Disable the feature entirely with **Settings → General → Quick Chat**; the s ## How it is wired -- Data plane: Gateway WS methods `chat.history`, `chat.message.get`, `chat.send`, `chat.abort`, `chat.inject`, and events `chat`, `agent`, `presence`, `tick`, `health`. +- Data plane: Gateway WS methods `chat.history`, `chat.message.get`, `chat.send`, `chat.abort`, `chat.inject`, plus `question.list` and `question.resolve`, and events `chat`, `agent`, `presence`, `tick`, `health`; question cards follow `question.requested` and `question.resolved` events and refresh from `question.list` after reconnects. - `chat.history` returns a display-normalized transcript: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (``, ``, ``, ``, including truncated blocks) and leaked model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY`/`no_reply` are omitted, and oversized rows can be replaced with a truncated placeholder. - Session: defaults to the primary session as above; the UI can switch between sessions. - Unread state: after a session activates and its live history loads successfully, the app clears that session's unread marker. Failed history loads do not clear it; a transient patch failure retries on the next activation. diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index 1130ff27999a..1126087208b1 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -227,12 +227,14 @@ cwd creates a fresh approval. Codex MCP tool approval elicitations route through OpenClaw's plugin approval flow when Codex marks `_meta.codex_approval_kind` as `"mcp_tool_call"`. Codex -`request_user_input` prompts are sent back to the originating chat. A single -non-secret choice uses typed channel buttons when the channel supports them, -and the Control UI shows non-secret questions as a structured card. The next -queued follow-up message answers that native server request instead of being -steered as extra context. Secret questions stay on the warned text-reply path. -Other MCP elicitation requests fail closed. +`request_user_input` registers a provider-neutral gateway question for the +originating session. The Control UI renders the gateway question card, and a +single non-secret choice uses typed channel buttons when the channel supports +them. Button taps, Control UI answers, and the next queued plain-text reply all +resolve the same gateway record before OpenClaw returns the app-server answer. +Codex auto-resolution and attempt aborts bound the wait and cancel the record. +Secret questions stay entirely on the warned text-reply path. Other MCP +elicitation requests fail closed. For the general plugin approval flow that carries these prompts, see [Plugin permission requests](/plugins/plugin-permission-requests). diff --git a/docs/plugins/copilot.md b/docs/plugins/copilot.md index bbdfd794c228..9087a43ee0d6 100755 --- a/docs/plugins/copilot.md +++ b/docs/plugins/copilot.md @@ -279,10 +279,10 @@ keeps `/btw` behavior identical to other non-Codex runtimes. surface. - PI session state does not migrate when an agent switches to `copilot`. Selection is per attempt; existing PI sessions remain valid. -- `ask_user` uses the same OpenClaw prompt-and-reply path as the Codex - harness: when the Copilot SDK asks for user input, OpenClaw posts a - blocking prompt to the active channel/TUI, and the next queued user - message resolves the SDK request. +- `ask_user` uses the provider-neutral gateway question runtime. The Control + UI shows the same question card as other OpenClaw questions, supported + channels render choice buttons, and the next queued plain-text message + resolves that gateway record before the SDK request returns. ## Permissions and ask_user @@ -361,10 +361,11 @@ a configured `github-copilot` auth profile). When the resolved mode is `useLoggedInUser`, the session-level field is omitted so the SDK keeps deriving identity from the logged-in identity. -`ask_user` uses `SessionConfig.onUserInputRequest`. The bridge accepts choice -indexes or labels for fixed-choice requests, accepts free-form answers when -the SDK request allows them, and cancels a pending request when the OpenClaw -attempt is aborted. +`ask_user` uses `SessionConfig.onUserInputRequest`. The bridge registers SDK +choices or option-less free-text prompts as gateway questions, accepts choice +indexes or labels for fixed-choice requests, and accepts free-form answers +when the SDK request allows them. Aborting the OpenClaw attempt cancels the +gateway record and returns an empty SDK answer. ## Related diff --git a/docs/plugins/message-presentation.md b/docs/plugins/message-presentation.md index 5d3ab9a739d0..788f69f5bf92 100644 --- a/docs/plugins/message-presentation.md +++ b/docs/plugins/message-presentation.md @@ -145,8 +145,12 @@ Button semantics: `ask_user` question. Like `approval`, this is an OpenClaw runtime action; agents and plugins must not synthesize question IDs. Telegram, Discord, and Slack map it to transport-private native callbacks and resolve the choice - through the Gateway. Other channels degrade the controls to label text, and - the user can answer with a plain-text reply. + through the Gateway. When the question becomes answered, expired, or + cancelled, those channels edit the delivered message, remove its actions, + and append the terminal status. WhatsApp, Signal, and iMessage render up to + four single-select choices as `1️⃣` through `4️⃣` reactions. Other question + shapes degrade to label text, and the user can answer with a plain-text + reply. - `action.type: "url"` opens a normal link. - `action.type: "web-app"` launches a channel-native web app. Set `url` for a URL-backed app or `widgetId` for an OpenClaw-hosted widget whose launch diff --git a/docs/tools/ask-user.md b/docs/tools/ask-user.md index 3a649af9a16f..1919a5b364d1 100644 --- a/docs/tools/ask-user.md +++ b/docs/tools/ask-user.md @@ -19,7 +19,10 @@ runs do not receive it. You can answer from any supported conversation surface: -- The web Control UI shows one unified question card. +- The web Control UI docks a question panel directly above the composer. For + multi-question prompts, the panel shows one question at a time and advances + through a short stepper. After resolution, the panel closes and the chat + keeps only a compact answer summary. - Telegram, Discord, and Slack render native buttons for a single-choice, single-question prompt. - A plain-text reply works on any channel. Reply with a number, an option label, @@ -29,8 +32,8 @@ OpenClaw always enables a free-text **Other** answer. The agent must not add an `Other` option to the authored option list. Prompts that cannot use native buttons, including multi-question and -multi-select prompts, degrade to readable text. The Control UI keeps the full -structured card. +multi-select prompts, degrade to readable text on channels. The Control UI +keeps the full structured stepper. ## Timeout and no answer diff --git a/extensions/codex/src/app-server/attempt-steering.ts b/extensions/codex/src/app-server/attempt-steering.ts index a1ac75d14b66..7794e38ce7c2 100644 --- a/extensions/codex/src/app-server/attempt-steering.ts +++ b/extensions/codex/src/app-server/attempt-steering.ts @@ -15,6 +15,7 @@ const CODEX_STEER_ALL_DEBOUNCE_MS = 500; export type CodexSteeringQueueOptions = { debounceMs?: number; images?: EmbeddedRunAttemptParams["images"]; + isInboundUserMessage?: boolean; }; /** diff --git a/extensions/codex/src/app-server/run-attempt-active-turn.ts b/extensions/codex/src/app-server/run-attempt-active-turn.ts index c8577c9e05f7..f75020b0efcb 100644 --- a/extensions/codex/src/app-server/run-attempt-active-turn.ts +++ b/extensions/codex/src/app-server/run-attempt-active-turn.ts @@ -1,4 +1,9 @@ -import { setActiveEmbeddedRun } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + cancelPendingAgentQuestionForSession, + claimPendingAgentQuestionAnswer, + embeddedAgentLog, + setActiveEmbeddedRun, +} from "openclaw/plugin-sdk/agent-harness-runtime"; import { interruptCodexTurnBestEffort, retireCodexAppServerClientAfterTimedOutTurn, @@ -146,8 +151,31 @@ export async function activateCodexAttemptTurn( const handle = { kind: "embedded" as const, runId: params.runId, - queueMessage: async (text: string, optionsLocal?: CodexSteeringQueueOptions) => - activeSteeringQueue.queue(text, optionsLocal), + queueMessage: async (text: string, optionsLocal?: CodexSteeringQueueOptions) => { + const isInboundUserMessage = optionsLocal?.isInboundUserMessage === true; + if (isInboundUserMessage && !optionsLocal?.images?.length) { + const claimed = await claimPendingAgentQuestionAnswer({ + sessionKey: params.sessionKey ?? params.sessionId, + text, + }); + if (claimed) { + return; + } + } else if (isInboundUserMessage) { + try { + await cancelPendingAgentQuestionForSession({ + sessionKey: params.sessionKey ?? params.sessionId, + resolvedBy: "image-reply", + }); + } catch (error) { + // Cleanup failure must not drop the user's image turn. + embeddedAgentLog.warn("failed to cancel codex gateway question before image steering", { + error, + }); + } + } + await activeSteeringQueue.queue(text, optionsLocal); + }, isStreaming: () => !state.completed && !runAbortController.signal.aborted, isStopped: () => state.completed || state.timedOut || runAbortController.signal.aborted, isAbortable: () => diff --git a/extensions/codex/src/app-server/run-attempt.steering.test.ts b/extensions/codex/src/app-server/run-attempt.steering.test.ts index eb1ca90af25b..c681456513ff 100644 --- a/extensions/codex/src/app-server/run-attempt.steering.test.ts +++ b/extensions/codex/src/app-server/run-attempt.steering.test.ts @@ -20,12 +20,44 @@ import { const activeRunRegistrationMocks = vi.hoisted(() => ({ clearActiveEmbeddedRun: vi.fn(), setActiveEmbeddedRun: vi.fn(), + questionWaiters: new Map void>(), + cancelQuestionError: undefined as Error | undefined, })); vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + cancelPendingAgentQuestionForSession: async ( + ...args: Parameters + ) => { + const error = activeRunRegistrationMocks.cancelQuestionError; + activeRunRegistrationMocks.cancelQuestionError = undefined; + if (error) { + throw error; + } + return await actual.cancelPendingAgentQuestionForSession(...args); + }, + callGatewayTool: async (...args: Parameters) => { + const [method, , rawParams] = args; + const params = rawParams as { id?: string; answers?: unknown; cancel?: boolean } | undefined; + if (method === "question.request") { + return { id: params?.id, expiresAtMs: Date.now() + 60_000 }; + } + if (method === "question.waitAnswer") { + return await new Promise((resolve) => { + activeRunRegistrationMocks.questionWaiters.set(params?.id ?? "", resolve); + }); + } + if (method === "question.resolve") { + const result = params?.cancel + ? { status: "cancelled" as const } + : { status: "answered" as const, answers: params?.answers }; + activeRunRegistrationMocks.questionWaiters.get(params?.id ?? "")?.(result); + return result; + } + return await actual.callGatewayTool(...args); + }, clearActiveEmbeddedRun: ( ...args: Parameters ): ReturnType => { @@ -165,6 +197,57 @@ describe("runCodexAppServerAttempt steering", () => { }); }); + it("still steers an image when gateway question cancellation fails", async () => { + const { requests, waitForMethod, completeTurn, notify } = createStartedThreadHarness(); + const params = createSteeringParams(); + const run = runCodexAppServerAttempt(params, { + pluginConfig: { appServer: { mode: "yolo" } }, + }); + await waitForMethod("turn/start"); + let handle: + | { + queueMessage: ( + text: string, + options?: Parameters[2], + ) => Promise; + } + | undefined; + await vi.waitFor(() => { + handle = activeRunRegistrationMocks.setActiveEmbeddedRun.mock.calls.findLast( + (call) => call[0] === params.sessionId, + )?.[1] as typeof handle; + expect(handle).toBeDefined(); + }, fastWait); + + activeRunRegistrationMocks.cancelQuestionError = new Error("gateway unavailable"); + const delivered = handle!.queueMessage("inspect this", { + debounceMs: 0, + images: [{ type: "image", data: PNG_1X1, mimeType: "image/png" }], + isInboundUserMessage: true, + }); + await vi.waitFor( + () => expect(requests.map((entry) => entry.method)).toContain("turn/steer"), + fastWait, + ); + const steer = requests.findLast((entry) => entry.method === "turn/steer"); + const clientUserMessageId = (steer?.params as { clientUserMessageId?: string } | undefined) + ?.clientUserMessageId; + if (!clientUserMessageId) { + throw new Error("turn/steer clientUserMessageId missing"); + } + await notify({ + method: "item/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { id: "steered-user-message", type: "userMessage", clientId: clientUserMessageId }, + }, + }); + await delivered; + await completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + }); + it("accepts message-tool-only steering for active Codex app-server source replies", async () => { const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(); const params = createSteeringParams(); @@ -344,7 +427,7 @@ describe("runCodexAppServerAttempt steering", () => { let handleRequest: | ((request: { id: string; method: string; params?: unknown }) => Promise) | undefined; - const request = vi.fn(async (method: string) => { + const request = vi.fn(async (method: string, _params?: unknown) => { if (method === "thread/start") { return threadStartResult(); } @@ -408,18 +491,30 @@ describe("runCodexAppServerAttempt steering", () => { }); await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1), fastWait); - await waitAndQueueActiveRunMessage(params.sessionId, "2"); + await waitAndQueueActiveRunMessage(params.sessionId, "tool progress", { debounceMs: 0 }); + await vi.waitFor( + () => expect(request.mock.calls.map(([method]) => method)).toContain("turn/steer"), + fastWait, + ); + const sourceSteer = request.mock.calls.findLast(([method]) => method === "turn/steer"); + const sourceMessageId = (sourceSteer?.[1] as { clientUserMessageId?: string } | undefined) + ?.clientUserMessageId; + if (!sourceMessageId) { + throw new Error("source turn/steer clientUserMessageId missing"); + } + await notify({ + method: "item/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { id: "source-message", type: "userMessage", clientId: sourceMessageId }, + }, + }); + await waitAndQueueActiveRunMessage(params.sessionId, "2", { isInboundUserMessage: true }); await expect(response).resolves.toEqual({ answers: { mode: { answers: ["Deep"] } }, }); - const requestCalls = request.mock.calls as unknown as Array<[string, unknown]>; - expect( - requestCalls.some( - ([method, callParams]) => - method === "turn/steer" && - (callParams as { expectedTurnId?: string } | undefined)?.expectedTurnId === "turn-1", - ), - ).toBe(false); + expect(request.mock.calls.filter(([method]) => method === "turn/steer")).toHaveLength(1); await notify({ method: "turn/completed", diff --git a/extensions/codex/src/app-server/user-input-actions.ts b/extensions/codex/src/app-server/user-input-actions.ts deleted file mode 100644 index dbb3144bc550..000000000000 --- a/extensions/codex/src/app-server/user-input-actions.ts +++ /dev/null @@ -1,90 +0,0 @@ -import crypto from "node:crypto"; -import type { AgentHarnessUserInputQuestion } from "openclaw/plugin-sdk/agent-harness-runtime"; -import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; - -export type CodexUserInputActionAnswer = - | { type: "choice"; optionIndex: number } - | { type: "answers"; answers: Record }; - -type PendingAnswer = (answer: CodexUserInputActionAnswer) => boolean; - -const pendingAnswers = new Map(); - -export function registerCodexUserInputActions(answer: PendingAnswer): { - token: string; - dispose: () => void; -} { - const token = crypto.randomUUID(); - pendingAnswers.set(token, answer); - return { - token, - dispose: () => pendingAnswers.delete(token), - }; -} - -export function resolveCodexUserInputAction( - token: string, - answer: CodexUserInputActionAnswer, -): boolean { - const pending = pendingAnswers.get(token); - if (!pending || !pending(answer)) { - return false; - } - pendingAnswers.delete(token); - return true; -} - -export function buildCodexUserInputPresentation( - questions: readonly AgentHarnessUserInputQuestion[], - token: string, -): MessagePresentation | undefined { - const question = questions.length === 1 ? questions[0] : undefined; - if (!question || question.isSecret || !question.options?.length) { - return undefined; - } - return { - title: question.header, - tone: "info", - blocks: [ - { - type: "buttons", - buttons: question.options.map((option, index) => { - const action = { - type: "command", - command: `/codex answer ${token} choice:${index}`, - } as const; - return index === 0 - ? { label: option.label, action, style: "primary" as const } - : { label: option.label, action }; - }), - }, - ], - }; -} - -export function parseCodexUserInputActionAnswer( - encoded: string, -): CodexUserInputActionAnswer | undefined { - const choiceMatch = encoded.match(/^choice:(\d+)$/u); - if (choiceMatch) { - const optionIndex = Number(choiceMatch[1]); - return Number.isSafeInteger(optionIndex) ? { type: "choice", optionIndex } : undefined; - } - if (!encoded.startsWith("answers:")) { - return undefined; - } - let parsed: unknown; - try { - parsed = JSON.parse(decodeURIComponent(encoded.slice("answers:".length))); - } catch { - return undefined; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined; - } - const entries = Object.entries(parsed); - if (entries.length === 0 || entries.some(([key, value]) => !key || typeof value !== "string")) { - return undefined; - } - return { type: "answers", answers: Object.fromEntries(entries) }; -} diff --git a/extensions/codex/src/app-server/user-input-bridge.test.ts b/extensions/codex/src/app-server/user-input-bridge.test.ts index 0747c0952538..2aa1c03f4c9d 100644 --- a/extensions/codex/src/app-server/user-input-bridge.test.ts +++ b/extensions/codex/src/app-server/user-input-bridge.test.ts @@ -1,517 +1,257 @@ -// Codex tests cover user input bridge plugin behavior. -import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +// Codex tests cover gateway-backed request_user_input behavior. +import { + claimPendingAgentQuestionAnswer, + type AgentHarnessQuestionGatewayCall, + type EmbeddedRunAttemptParams, +} from "openclaw/plugin-sdk/agent-harness-runtime"; import { describe, expect, it, vi } from "vitest"; -import { resolveCodexUserInputAction } from "./user-input-actions.js"; import { createCodexUserInputBridge } from "./user-input-bridge.js"; -function createParams(): EmbeddedRunAttemptParams { +type GatewayCallRecord = { method: string; opts: unknown; params: unknown }; + +function createParams(signal?: AbortSignal): EmbeddedRunAttemptParams { return { sessionId: "session-1", sessionKey: "agent:main:session-1", + agentId: "main", + timeoutMs: 90_000, onBlockReply: vi.fn(), onAgentEvent: vi.fn(), + abortSignal: signal, } as unknown as EmbeddedRunAttemptParams; } -function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string { - const onBlockReply = params.onBlockReply; - if (onBlockReply === undefined) { - throw new Error("Expected onBlockReply callback"); - } - const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0]; - if (typeof payload?.text !== "string") { - throw new Error("Expected first block reply text"); - } - return payload.text; +function createGatewayStub() { + const calls: GatewayCallRecord[] = []; + let settleWait: ((value: unknown) => void) | undefined; + const wait = new Promise((resolve) => { + settleWait = resolve; + }); + const call: AgentHarnessQuestionGatewayCall = async (method, opts, params) => { + calls.push({ method, opts, params }); + if (method === "question.request") { + return { id: (params as { id: string }).id, expiresAtMs: Date.now() + 90_000 }; + } + if (method === "question.waitAnswer") { + return await wait; + } + if (method === "question.resolve") { + const resolveParams = params as { + answers?: { answers: Record }; + cancel?: boolean; + }; + const result = resolveParams.cancel + ? { status: "cancelled" as const } + : { status: "answered" as const, answers: resolveParams.answers! }; + settleWait?.(result); + return result; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + return { call, calls }; +} + +function requestParams(overrides: Record = {}) { + return { + threadId: "thread-1", + turnId: "turn-1", + itemId: "tool-1", + questions: [ + { + id: "choice", + header: "Mode", + question: "Pick a mode", + isOther: false, + isSecret: false, + options: [ + { label: "Fast", description: "Use less reasoning" }, + { label: "Deep", description: "Use more reasoning" }, + ], + }, + ], + ...overrides, + }; } describe("Codex app-server user input bridge", () => { - it("prompts the originating chat and resolves request_user_input from the next queued message", async () => { + it("registers, presents, claims, and returns gateway answers", async () => { const params = createParams(); + const gateway = createGatewayStub(); const bridge = createCodexUserInputBridge({ paramsForRun: params, threadId: "thread-1", turnId: "turn-1", + gatewayCall: gateway.call, }); - const response = bridge.handleRequest({ - id: "input-1", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-1", - questions: [ - { - id: "choice", - header: "Mode", - question: "Pick a mode", - isOther: false, - isSecret: false, - options: [ - { label: "Fast", description: "Use less reasoning" }, - { label: "Deep", description: "Use more reasoning" }, - ], - }, - ], - }, - }); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - expect(expectFirstBlockReplyText(params)).toContain("Pick a mode"); - expect(bridge.handleQueuedMessage("2")).toBe(true); - - await expect(response).resolves.toEqual({ - answers: { choice: { answers: ["Deep"] } }, - }); - }); - - it("emits a web question card and typed channel actions", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - - const response = bridge.handleRequest({ - id: "input-actions", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-actions", - questions: [ - { - id: "mode", - header: "Mode", - question: "Pick a mode", - isOther: false, - isSecret: false, - options: [{ label: "Fast" }, { label: "Deep" }], - }, - ], - }, - }); - + const response = bridge.handleRequest({ id: "input-1", params: requestParams() }); await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); - expect(params.onAgentEvent).toHaveBeenCalledWith({ - stream: "question", - data: expect.objectContaining({ phase: "requested", itemId: "tool-actions" }), - }); - const presentation = vi.mocked(params.onBlockReply!).mock.calls[0]?.[0].presentation; - const block = presentation?.blocks[0]; - expect(block?.type).toBe("buttons"); - if (block?.type !== "buttons") { - throw new Error("expected typed question buttons"); + + const request = gateway.calls.find((entry) => entry.method === "question.request"); + if (!request) { + throw new Error("expected question.request"); } - const action = block.buttons[1]?.action; - expect(action?.type).toBe("command"); - if (action?.type !== "command") { - throw new Error("expected command action"); - } - const match = action.command.match(/^\/codex answer ([0-9a-f-]+) choice:1$/u); - expect(match).not.toBeNull(); - expect(resolveCodexUserInputAction(match![1]!, { type: "choice", optionIndex: 1 })).toBe(true); - - await expect(response).resolves.toEqual({ answers: { mode: { answers: ["Deep"] } } }); - expect(params.onAgentEvent).toHaveBeenLastCalledWith({ - stream: "question", - data: expect.objectContaining({ phase: "resolved", itemId: "tool-actions" }), + expect(request?.params).toMatchObject({ + sessionKey: "agent:main:session-1", + agentId: "main", + timeoutMs: 90_000, + questions: requestParams().questions, }); - }); - - it("keeps numeric labels distinct from typed option indexes", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", + const payload = vi.mocked(params.onBlockReply!).mock.calls[0]![0]; + expect(payload.channelData).toEqual({ + askUser: { questionId: (request.params as { id: string }).id }, }); - const response = bridge.handleRequest({ - id: "input-numeric-label", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-numeric-label", - questions: [ - { - id: "mode", - header: "Mode", - question: "Pick a mode", - isOther: false, - isSecret: false, - options: [{ label: "2" }, { label: "Deep" }], - }, - ], - }, - }); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); - const event = vi - .mocked(params.onAgentEvent!) - .mock.calls.find(([payload]) => payload.stream === "question")?.[0]; - const actionId = - event && typeof event.data === "object" && event.data && "actionToken" in event.data - ? event.data.actionToken - : undefined; - expect(typeof actionId).toBe("string"); - expect( - resolveCodexUserInputAction(String(actionId), { - type: "answers", - answers: { mode: "2" }, - }), - ).toBe(true); - await expect(response).resolves.toEqual({ answers: { mode: { answers: ["2"] } } }); - }); - - it("preserves case-distinct structured option labels", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - const response = bridge.handleRequest({ - id: "input-case-label", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-case-label", - questions: [ - { - id: "mode", - header: "Mode", - question: "Pick a mode", - isOther: false, - isSecret: false, - options: [{ label: "FAST" }, { label: " fast " }], - }, - ], - }, - }); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); - const event = vi - .mocked(params.onAgentEvent!) - .mock.calls.find(([payload]) => payload.stream === "question")?.[0]; - const actionId = - event && typeof event.data === "object" && event.data && "actionToken" in event.data - ? event.data.actionToken - : undefined; - expect( - resolveCodexUserInputAction(String(actionId), { - type: "answers", - answers: { mode: " fast " }, - }), - ).toBe(true); - await expect(response).resolves.toEqual({ answers: { mode: { answers: [" fast "] } } }); - }); - - it("preserves reserved question ids in structured answers", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - const response = bridge.handleRequest({ - id: "input-reserved-id", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-reserved-id", - questions: [ - { - id: "__proto__", - header: "Mode", - question: "Pick a mode", - isOther: false, - isSecret: false, - options: [{ label: "Safe" }], - }, - ], - }, - }); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); - const event = vi - .mocked(params.onAgentEvent!) - .mock.calls.find(([payload]) => payload.stream === "question")?.[0]; - const actionId = - event && typeof event.data === "object" && event.data && "actionToken" in event.data - ? event.data.actionToken - : undefined; - expect( - resolveCodexUserInputAction(String(actionId), { - type: "answers", - answers: Object.fromEntries([["__proto__", "Safe"]]), - }), - ).toBe(true); - await expect(response).resolves.toEqual({ - answers: Object.fromEntries([["__proto__", { answers: ["Safe"] }]]), - }); - }); - - it("does not expose secret questions as channel buttons", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - const response = bridge.handleRequest({ - id: "input-secret", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-secret", - questions: [ - { - id: "secret", - header: "Secret", - question: "Enter it", - isOther: true, - isSecret: true, - options: [{ label: "Stored value" }], - }, - ], - }, - }); - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); - expect(vi.mocked(params.onBlockReply!).mock.calls[0]?.[0].presentation).toBeUndefined(); - expect(params.onAgentEvent).toHaveBeenCalledWith({ - stream: "question", - data: expect.not.objectContaining({ actionToken: expect.anything() }), - }); - expect(bridge.handleQueuedMessage("private")).toBe(true); - await response; - }); - - it("does not let a captured handle settle a replacement request", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - const question = { - id: "answer", - header: "Answer", - question: "Continue?", - isOther: true, - isSecret: false, - options: null, - }; - const firstResponse = bridge.handleRequest({ - id: "input-first", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-first", - questions: [question], - }, - }); - const firstHandle = bridge.claimPendingRequest(); - expect(firstHandle).toBeDefined(); - - const secondResponse = bridge.handleRequest({ - id: "input-second", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-second", - questions: [question], - }, - }); - - await expect(firstResponse).resolves.toEqual({ answers: {} }); - expect(firstHandle?.cancel()).toBe(false); - expect(bridge.claimPendingRequest()).toBeDefined(); - bridge.cancelPending(); - await expect(secondResponse).resolves.toEqual({ answers: {} }); - }); - - it("maps keyed multi-question replies to Codex answer ids", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - - const response = bridge.handleRequest({ - id: "input-2", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-1", - questions: [ - { - id: "repo", - header: "Repository", - question: "Which repo?", - isOther: true, - isSecret: false, - options: null, - }, - { - id: "scope", - header: "Scope", - question: "Which scope?", - isOther: false, - isSecret: false, - options: [{ label: "Tests", description: "Only tests" }], - }, - ], - }, - }); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - expect(bridge.handleQueuedMessage("repo: openclaw\nscope: Tests")).toBe(true); - - await expect(response).resolves.toEqual({ - answers: { - repo: { answers: ["openclaw"] }, - scope: { answers: ["Tests"] }, - }, - }); - }); - - it("rejects free-form option replies when Other is disabled", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - - const response = bridge.handleRequest({ - id: "input-options", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-1", - questions: [ - { - id: "mode", - header: "Mode", - question: "Pick a mode", - isOther: false, - isSecret: false, - options: [{ label: "Fast", description: "Use less reasoning" }], - }, - ], - }, - }); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - expect(bridge.handleQueuedMessage("banana")).toBe(true); - - await expect(response).resolves.toEqual({ - answers: { mode: { answers: [] } }, - }); - }); - - it("escapes prompt question and option text before chat display", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - - const response = bridge.handleRequest({ - id: "input-escaped", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-1", - questions: [ - { - id: "mode", - header: "Mode <@U123>", - question: "Pick [trusted](https://evil) @here", - isOther: false, - isSecret: false, - options: [{ label: "Fast <@U123>", description: "Use [less](https://evil)" }], - }, - ], - }, - }); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - const text = expectFirstBlockReplyText(params); - expect(text).toContain("Mode <\uff20U123>"); - expect(text).toContain("Pick \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here"); - expect(text).toContain( - "Fast <\uff20U123> - Use \uff3bless\uff3d\uff08https://evil\uff09", - ); - expect(text).not.toContain("<@U123>"); - expect(text).not.toContain("[trusted](https://evil)"); - expect(text).not.toContain("@here"); - - expect(bridge.handleQueuedMessage("1")).toBe(true); - await expect(response).resolves.toEqual({ - answers: { mode: { answers: ["Fast <@U123>"] } }, - }); - }); - - it("clears pending prompts when Codex resolves the server request itself", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", - }); - - const response = bridge.handleRequest({ - id: "input-3", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-1", - questions: [ - { - id: "answer", - header: "Answer", - question: "Continue?", - isOther: true, - isSecret: false, - options: null, - }, - ], - }, - }); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - bridge.handleNotification({ - method: "serverRequest/resolved", - params: { threadId: "thread-1", requestId: "input-3" }, - }); - - await expect(response).resolves.toEqual({ answers: {} }); - expect(bridge.handleQueuedMessage("too late")).toBe(false); - }); - - it("resolves malformed empty question prompts without waiting for chat input", async () => { - const params = createParams(); - const bridge = createCodexUserInputBridge({ - paramsForRun: params, - threadId: "thread-1", - turnId: "turn-1", + expect(payload.presentationTextMode).toBe("fallback"); + expect(payload.text).toContain("Reply with the number or option text."); + expect(payload.text).not.toContain("your own answer"); + const buttons = payload.presentation?.blocks.find((block) => block.type === "buttons"); + expect(buttons?.type === "buttons" ? buttons.buttons[1]?.action : undefined).toMatchObject({ + type: "question", + optionValue: "Deep", }); await expect( - bridge.handleRequest({ - id: "input-empty", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "tool-1", - questions: [], - }, + claimPendingAgentQuestionAnswer({ sessionKey: params.sessionKey, text: "2" }), + ).resolves.toBe(true); + await expect(response).resolves.toEqual({ answers: { choice: { answers: ["Deep"] } } }); + }); + + it("cancels the gateway record on run abort", async () => { + const controller = new AbortController(); + const params = createParams(controller.signal); + const gateway = createGatewayStub(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + signal: controller.signal, + gatewayCall: gateway.call, + }); + const response = bridge.handleRequest({ id: "input-abort", params: requestParams() }); + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); + controller.abort(); + + await expect(response).resolves.toEqual({ answers: {} }); + expect(gateway.calls).toContainEqual( + expect.objectContaining({ + method: "question.resolve", + params: expect.objectContaining({ cancel: true, resolvedBy: "run-abort" }), }), + ); + }); + + it("does not register a gateway question after the run already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const params = createParams(controller.signal); + const gateway = createGatewayStub(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + signal: controller.signal, + gatewayCall: gateway.call, + }); + + await expect( + bridge.handleRequest({ id: "input-already-aborted", params: requestParams() }), ).resolves.toEqual({ answers: {} }); + expect(gateway.calls).toEqual([]); expect(params.onBlockReply).not.toHaveBeenCalled(); - expect(bridge.handleQueuedMessage("late answer")).toBe(false); + }); + + it("keeps secret questions on the warned text-only path", async () => { + const params = createParams(); + const gateway = createGatewayStub(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + gatewayCall: gateway.call, + }); + const response = bridge.handleRequest({ + id: "input-secret", + params: requestParams({ + questions: [ + { + id: "token", + header: "Secret", + question: "Enter token", + isOther: true, + isSecret: true, + options: null, + }, + ], + }), + }); + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); + + const payload = vi.mocked(params.onBlockReply!).mock.calls[0]![0]; + expect(payload.text).toContain("This channel may show your reply"); + expect(payload.channelData).toBeUndefined(); + expect(gateway.calls).toHaveLength(0); + expect(bridge.claimPendingRequest()?.answer("private")).toBe(true); + await expect(response).resolves.toEqual({ answers: { token: { answers: ["private"] } } }); + }); + + it("cancels the matching gateway record on serverRequest/resolved", async () => { + const params = createParams(); + const gateway = createGatewayStub(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + gatewayCall: gateway.call, + }); + const response = bridge.handleRequest({ id: 42, params: requestParams() }); + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); + bridge.handleNotification({ + method: "serverRequest/resolved", + params: { threadId: "thread-1", requestId: 42 }, + }); + + await expect(response).resolves.toEqual({ answers: {} }); + expect(gateway.calls).toContainEqual( + expect.objectContaining({ + method: "question.resolve", + params: expect.objectContaining({ cancel: true, resolvedBy: "run-abort" }), + }), + ); + }); + + it("passes Codex autoResolutionMs and option-less free text through", async () => { + const params = createParams(); + const gateway = createGatewayStub(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + gatewayCall: gateway.call, + }); + const response = bridge.handleRequest({ + id: "input-free", + params: requestParams({ + autoResolutionMs: 60_000, + questions: [ + { + id: "notes", + header: "Notes", + question: "What should change?", + isOther: true, + isSecret: false, + options: null, + }, + ], + }), + }); + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); + expect(gateway.calls[0]?.params).toMatchObject({ + timeoutMs: 60_000, + questions: [expect.objectContaining({ id: "notes", options: [] })], + }); + await claimPendingAgentQuestionAnswer({ sessionKey: params.sessionKey, text: "Refactor it" }); + await expect(response).resolves.toEqual({ + answers: { notes: { answers: ["Refactor it"] } }, + }); }); }); diff --git a/extensions/codex/src/app-server/user-input-bridge.ts b/extensions/codex/src/app-server/user-input-bridge.ts index d96bae18e71b..820e92c1a3c9 100644 --- a/extensions/codex/src/app-server/user-input-bridge.ts +++ b/extensions/codex/src/app-server/user-input-bridge.ts @@ -1,12 +1,12 @@ -/** - * Bridges Codex item/tool user-input requests to OpenClaw messaging prompts and - * turns replies into app-server answer payloads. - */ +/** Bridges Codex request_user_input calls to gateway questions and secret text prompts. */ import { buildAgentHarnessUserInputAnswers, + callGatewayTool, deliverAgentHarnessUserInputPrompt, embeddedAgentLog, emptyAgentHarnessUserInputAnswers, + runAgentHarnessGatewayQuestion, + type AgentHarnessQuestionGatewayCall, type AgentHarnessUserInputOption, type AgentHarnessUserInputQuestion, type EmbeddedRunAttemptParams, @@ -18,24 +18,24 @@ import { type JsonObject, type JsonValue, } from "./protocol.js"; -import { emitCodexAppServerEvent } from "./run-attempt-lifecycle.js"; -import { - buildCodexUserInputPresentation, - registerCodexUserInputActions, - type CodexUserInputActionAnswer, -} from "./user-input-actions.js"; -type PendingUserInput = { +const DEFAULT_USER_INPUT_TIMEOUT_MS = 15 * 60_000; + +type PendingSecretUserInput = { requestId: number | string; threadId: string; - turnId: string; - itemId: string; questions: AgentHarnessUserInputQuestion[]; claimed: boolean; resolve: (value: JsonValue) => void; cleanup: () => void; }; +type PendingGatewayUserInput = { + requestId: number | string; + threadId: string; + abort: AbortController; +}; + type CodexUserInputBridge = { handleRequest: (request: { id: number | string; @@ -47,7 +47,6 @@ type CodexUserInputBridge = { cancel: () => boolean; } | undefined; - handleQueuedMessage: (text: string) => boolean; handleNotification: (notification: CodexServerNotification) => void; cancelPending: () => void; }; @@ -58,27 +57,34 @@ export function createCodexUserInputBridge(params: { threadId: string; turnId: string; signal?: AbortSignal; + gatewayCall?: AgentHarnessQuestionGatewayCall; }): CodexUserInputBridge { - let pending: PendingUserInput | undefined; + let sensitiveInput: PendingSecretUserInput | undefined; + let pendingGateway: PendingGatewayUserInput | undefined; + const gatewayCall = params.gatewayCall ?? callGatewayTool; - const resolvePending = (value: JsonValue) => { - const current = pending; + const resolveSecret = (value: JsonValue) => { + const current = sensitiveInput; if (!current) { return; } - pending = undefined; + sensitiveInput = undefined; current.cleanup(); current.resolve(value); }; - const resolvePendingIfCurrent = (current: PendingUserInput, value: JsonValue): boolean => { - if (pending !== current) { + const resolveSecretIfCurrent = (current: PendingSecretUserInput, value: JsonValue): boolean => { + if (sensitiveInput !== current) { return false; } - resolvePending(value); + resolveSecret(value); return true; }; + const cancelGateway = () => { + pendingGateway?.abort.abort(new Error("Codex user input request cancelled")); + }; + return { async handleRequest(request) { const requestParams = readUserInputParams(request.params); @@ -92,94 +98,114 @@ export function createCodexUserInputBridge(params: { return emptyUserInputResponse(); } - resolvePending(emptyUserInputResponse()); + resolveSecret(emptyUserInputResponse()); + cancelGateway(); - return new Promise((resolve) => { - const abortListener = () => resolvePending(emptyUserInputResponse()); - let disposeAction = () => {}; - const cleanup = () => { - params.signal?.removeEventListener("abort", abortListener); - disposeAction(); - emitQuestionEvent(params.paramsForRun, "resolved", requestParams.itemId); - }; - const current: PendingUserInput = { - requestId: request.id, - threadId: requestParams.threadId, - turnId: requestParams.turnId, - itemId: requestParams.itemId, - questions: requestParams.questions, - claimed: false, - resolve, - cleanup, - }; - const actionRegistration = requestParams.questions.some((question) => question.isSecret) - ? undefined - : registerCodexUserInputActions((answer) => { - const response = buildUserInputActionResponse(requestParams.questions, answer); - return response ? resolvePendingIfCurrent(current, response) : false; - }); - disposeAction = actionRegistration?.dispose ?? disposeAction; - pending = current; - params.signal?.addEventListener("abort", abortListener, { once: true }); - if (params.signal?.aborted) { - resolvePending(emptyUserInputResponse()); - return; - } - emitQuestionEvent( - params.paramsForRun, - "requested", - requestParams.itemId, - requestParams.questions, - actionRegistration?.token, - ); - void deliverUserInputPrompt( - params.paramsForRun, - requestParams.questions, - actionRegistration?.token, - ).catch((error: unknown) => { - embeddedAgentLog.warn("failed to deliver codex user input prompt", { error }); + if (requestParams.questions.some((question) => question.isSecret)) { + return new Promise((resolve) => { + const abortListener = () => resolveSecret(emptyUserInputResponse()); + const cleanup = () => params.signal?.removeEventListener("abort", abortListener); + const current: PendingSecretUserInput = { + requestId: request.id, + threadId: requestParams.threadId, + questions: requestParams.questions, + claimed: false, + resolve, + cleanup, + }; + sensitiveInput = current; + params.signal?.addEventListener("abort", abortListener, { once: true }); + if (params.signal?.aborted) { + resolveSecret(emptyUserInputResponse()); + return; + } + void deliverAgentHarnessUserInputPrompt(params.paramsForRun, requestParams.questions, { + formatText: formatCodexDisplayText, + intro: "Codex needs input:", + }).catch((error: unknown) => { + embeddedAgentLog.warn("failed to deliver secret codex user input prompt", { error }); + }); }); - }); + } + + const abort = new AbortController(); + const abortFromRun = () => abort.abort(params.signal?.reason); + params.signal?.addEventListener("abort", abortFromRun, { once: true }); + if (params.signal?.aborted) { + abortFromRun(); + } + pendingGateway = { requestId: request.id, threadId: requestParams.threadId, abort }; + try { + const result = await runAgentHarnessGatewayQuestion({ + questions: requestParams.questions, + sessionKey: params.paramsForRun.sessionKey ?? params.paramsForRun.sessionId, + agentId: params.paramsForRun.agentId, + timeoutMs: + requestParams.autoResolutionMs ?? + params.paramsForRun.timeoutMs ?? + DEFAULT_USER_INPUT_TIMEOUT_MS, + gatewayCall, + delivery: params.paramsForRun, + promptOptions: { + formatText: formatCodexDisplayText, + intro: "Codex needs input:", + }, + signal: abort.signal, + }); + return result.status === "answered" + ? (result.answers as unknown as JsonObject) + : emptyUserInputResponse(); + } catch (error) { + embeddedAgentLog.warn("failed to bridge codex user input through gateway", { error }); + return emptyUserInputResponse(); + } finally { + params.signal?.removeEventListener("abort", abortFromRun); + if (pendingGateway?.abort === abort) { + pendingGateway = undefined; + } + } }, claimPendingRequest() { - const current = pending; + const current = sensitiveInput; if (!current || current.claimed) { return undefined; } current.claimed = true; return { answer: (text) => - resolvePendingIfCurrent(current, buildUserInputResponse(current.questions, text)), - cancel: () => resolvePendingIfCurrent(current, emptyUserInputResponse()), + resolveSecretIfCurrent(current, buildUserInputResponse(current.questions, text)), + cancel: () => resolveSecretIfCurrent(current, emptyUserInputResponse()), }; }, - handleQueuedMessage(text) { - const current = pending; - if (!current || current.claimed) { - return false; - } - resolvePending(buildUserInputResponse(current.questions, text)); - return true; - }, handleNotification(notification) { - if (notification.method !== "serverRequest/resolved" || !pending) { + if (notification.method !== "serverRequest/resolved") { return; } const notificationParams = isJsonObject(notification.params) ? notification.params : undefined; const requestId = notificationParams ? readRequestId(notificationParams) : undefined; + if (!notificationParams || requestId === undefined) { + return; + } if ( - notificationParams && - readString(notificationParams, "threadId") === pending.threadId && - requestId !== undefined && - String(requestId) === String(pending.requestId) + sensitiveInput && + readString(notificationParams, "threadId") === sensitiveInput.threadId && + String(requestId) === String(sensitiveInput.requestId) ) { - resolvePending(emptyUserInputResponse()); + resolveSecret(emptyUserInputResponse()); + } + if ( + pendingGateway && + readString(notificationParams, "threadId") === pendingGateway.threadId && + String(requestId) === String(pendingGateway.requestId) + ) { + pendingGateway.abort.abort(new Error("Codex server request resolved")); } }, cancelPending() { - resolvePending(emptyUserInputResponse()); + resolveSecret(emptyUserInputResponse()); + cancelGateway(); }, }; } @@ -190,6 +216,7 @@ function readUserInputParams(value: JsonValue | undefined): turnId: string; itemId: string; questions: AgentHarnessUserInputQuestion[]; + autoResolutionMs?: number; } | undefined { if (!isJsonObject(value)) { @@ -203,9 +230,19 @@ function readUserInputParams(value: JsonValue | undefined): return undefined; } const questions = questionsRaw - .map(readQuestion) + .map((rawQuestion) => { + const question = readQuestion(rawQuestion); + if (question && isJsonObject(rawQuestion) && rawQuestion.multiSelect === true) { + question.multiSelect = true; + } + return question; + }) .filter((question): question is AgentHarnessUserInputQuestion => Boolean(question)); - return { threadId, turnId, itemId, questions }; + const autoResolutionMs = + typeof value.autoResolutionMs === "number" && value.autoResolutionMs > 0 + ? value.autoResolutionMs + : undefined; + return { threadId, turnId, itemId, questions, autoResolutionMs }; } function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefined { @@ -247,78 +284,13 @@ function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined { return label ? { label, description } : undefined; } -async function deliverUserInputPrompt( - params: EmbeddedRunAttemptParams, - questions: AgentHarnessUserInputQuestion[], - actionToken?: string, -): Promise { - await deliverAgentHarnessUserInputPrompt(params, questions, { - formatText: formatCodexDisplayText, - intro: "Codex needs input:", - presentation: actionToken ? buildCodexUserInputPresentation(questions, actionToken) : undefined, - }); -} - -function emitQuestionEvent( - params: EmbeddedRunAttemptParams, - phase: "requested" | "resolved", - itemId: string, - questions?: AgentHarnessUserInputQuestion[], - actionToken?: string, -): void { - void emitCodexAppServerEvent(params, { - stream: "question", - data: { - phase, - itemId, - source: "codex-app-server", - ...(questions ? { questions } : {}), - ...(actionToken ? { actionToken } : {}), - }, - }); -} - function buildUserInputResponse( questions: AgentHarnessUserInputQuestion[], inputText: string, ): JsonObject { - // Multi-question replies may use "header: answer" or numbered lines. Keep the - // parser permissive so chat-channel replies remain ergonomic. return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject; } -function buildUserInputActionResponse( - questions: AgentHarnessUserInputQuestion[], - action: CodexUserInputActionAnswer, -): JsonObject | undefined { - if (action.type === "choice") { - const question = questions.length === 1 ? questions[0] : undefined; - const option = question?.options?.[action.optionIndex]; - return question && option - ? ({ answers: { [question.id]: { answers: [option.label] } } } as JsonObject) - : undefined; - } - if ( - Object.keys(action.answers).length !== questions.length || - !questions.every((question) => Object.hasOwn(action.answers, question.id)) - ) { - return undefined; - } - const answerEntries: Array<[string, { answers: string[] }]> = []; - for (const question of questions) { - const rawAnswer = action.answers[question.id]; - if (!rawAnswer?.trim()) { - return undefined; - } - const exactOption = question.options?.find((option) => option.label === rawAnswer); - if (!exactOption && question.options?.length && !question.isOther) { - return undefined; - } - answerEntries.push([question.id, { answers: [exactOption?.label ?? rawAnswer] }]); - } - return { answers: Object.fromEntries(answerEntries) } as unknown as JsonObject; -} - function emptyUserInputResponse(): JsonObject { return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject; } diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 0709dd1ff79c..2c8d89927cb0 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -38,10 +38,6 @@ import { type CodexAppServerBindingStore, type CodexAppServerThreadBinding, } from "./app-server/session-binding.js"; -import { - parseCodexUserInputActionAnswer, - resolveCodexUserInputAction, -} from "./app-server/user-input-actions.js"; import { readCodexAccountAuthOverview } from "./command-account.js"; import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js"; import { @@ -382,24 +378,6 @@ export async function handleCodexSubcommand( if (normalized === "help") { return { text: buildHelp() }; } - if (normalized === "answer") { - if (rest.length !== 2) { - return { text: "Usage: /codex answer " }; - } - if (!canMutateCodexHost(ctx)) { - return { text: CODEX_NATIVE_EXECUTION_AUTH_ERROR }; - } - const [token = "", encodedAnswer = ""] = rest; - const answer = parseCodexUserInputActionAnswer(encodedAnswer); - if (!answer) { - return { text: "Invalid Codex answer encoding." }; - } - return { - text: resolveCodexUserInputAction(token, answer) - ? "Answered Codex." - : "That Codex question is no longer pending.", - }; - } if ( CODEX_NATIVE_CONTROL_SUBCOMMANDS.has(normalized) && !returnsBeforeNativeCodexExecution(normalized, rest) && diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 24060d654fda..d52bbf9d738a 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -23,7 +23,6 @@ import { testCodexAppServerBindingStore, } from "./app-server/session-binding.test-helpers.js"; import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js"; -import { registerCodexUserInputActions } from "./app-server/user-input-actions.js"; import { codexDiagnosticsFeedbackState } from "./command-diagnostics-state.js"; import { handleCodexCommand } from "./command-dispatch.js"; import type { CodexCommandDepsOverride } from "./command-handlers.js"; @@ -387,39 +386,6 @@ describe("codex command", () => { expect(handler).toHaveBeenCalledTimes(1); }); - it("authorizes and decodes request-scoped Codex answers", async () => { - const answer = vi.fn(() => true); - const registration = registerCodexUserInputActions(answer); - await expect( - handleCodexCommand( - createContext( - `answer ${registration.token} answers:%7B%22mode%22%3A%22Deep%20mode%22%7D`, - undefined, - { - senderIsOwner: false, - }, - ), - { deps: createDeps() }, - ), - ).resolves.toEqual({ - text: "Only an owner or operator.admin can control Codex native execution.", - }); - expect(answer).not.toHaveBeenCalled(); - - await expect( - handleCodexCommand( - createContext(`answer ${registration.token} answers:%7B%22mode%22%3A%22Deep%20mode%22%7D`), - { - deps: createDeps(), - }, - ), - ).resolves.toEqual({ text: "Answered Codex." }); - expect(answer).toHaveBeenCalledWith({ - type: "answers", - answers: { mode: "Deep mode" }, - }); - }); - it("renders the top-level Codex menu as portable native slash commands", async () => { const result = await handleCodexCommand(createContext(""), { deps: createDeps() }); diff --git a/extensions/copilot/src/attempt.test.ts b/extensions/copilot/src/attempt.test.ts index 6ba7f741f0a2..64683b3650e2 100644 --- a/extensions/copilot/src/attempt.test.ts +++ b/extensions/copilot/src/attempt.test.ts @@ -22,6 +22,51 @@ import { runCopilotAttempt } from "./attempt.js"; import type { CopilotClientPool } from "./runtime.js"; import type { createCopilotToolBridge } from "./tool-bridge.js"; +const gatewayQuestionMock = vi.hoisted(() => ({ + waiters: new Map void>(), + cancelError: undefined as Error | undefined, + warn: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + embeddedAgentLog: { ...actual.embeddedAgentLog, warn: gatewayQuestionMock.warn }, + cancelPendingAgentQuestionForSession: async ( + ...args: Parameters + ) => { + const error = gatewayQuestionMock.cancelError; + gatewayQuestionMock.cancelError = undefined; + if (error) { + throw error; + } + return await actual.cancelPendingAgentQuestionForSession(...args); + }, + callGatewayTool: async (...args: Parameters) => { + const [method, , rawParams] = args; + const params = rawParams as { id?: string; answers?: unknown; cancel?: boolean } | undefined; + if (method === "question.request") { + return { id: params?.id, expiresAtMs: Date.now() + 60_000 }; + } + if (method === "question.waitAnswer") { + return await new Promise((resolve) => { + gatewayQuestionMock.waiters.set(params?.id ?? "", resolve); + }); + } + if (method === "question.resolve") { + const result = params?.cancel + ? { status: "cancelled" as const } + : { status: "answered" as const, answers: params?.answers }; + gatewayQuestionMock.waiters.get(params?.id ?? "")?.(result); + gatewayQuestionMock.waiters.delete(params?.id ?? ""); + return result; + } + return await actual.callGatewayTool(...args); + }, + }; +}); + type CopilotToolBridgeInput = Parameters[0]; const TINY_PNG_BASE64 = @@ -1332,12 +1377,19 @@ describe("runCopilotAttempt", () => { const session = await sessionCreated.promise; await vi.waitFor(() => expect(session.sendAndWait).toHaveBeenCalledTimes(1)); + gatewayQuestionMock.cancelError = new Error("gateway unavailable"); expect(abortAgentHarnessRun("session-1")).toBe(true); const result = await runPromise; expect(session.abort).toHaveBeenCalledTimes(1); expect(result.aborted).toBe(true); expect(result.externalAbort).toBe(true); + await vi.waitFor(() => + expect(gatewayQuestionMock.warn).toHaveBeenCalledWith( + "failed to cancel copilot gateway question during shutdown", + expect.objectContaining({ error: expect.any(Error) }), + ), + ); }); it("abort path (signal already aborted)", async () => { @@ -1642,7 +1694,9 @@ describe("runCopilotAttempt", () => { const attempt = runCopilotAttempt(makeParams({ onBlockReply }), { pool }); await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledTimes(1)); - expect(queueAgentHarnessMessage("session-1", "2")).toBe(true); + expect(queueAgentHarnessMessage("session-1", "tool progress")).toBe(true); + await waitForEventLoopTurn(); + expect(queueAgentHarnessMessage("session-1", "2", { isInboundUserMessage: true })).toBe(true); const result = await attempt; const cfg = requireCreateSessionConfig(sdk); diff --git a/extensions/copilot/src/attempt.ts b/extensions/copilot/src/attempt.ts index 1e9bf44823b7..e36a07b37169 100644 --- a/extensions/copilot/src/attempt.ts +++ b/extensions/copilot/src/attempt.ts @@ -9,7 +9,10 @@ import type { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { buildAgentHookContextChannelFields, + cancelPendingAgentQuestionForSession, + claimPendingAgentQuestionAnswer, detectAndLoadAgentHarnessPromptImages, + embeddedAgentLog, getModelProviderRequestTransport, isHostScopedAgentToolActive, resolveAgentHarnessBeforePromptBuildResult, @@ -898,10 +901,28 @@ export async function runCopilotAttempt( isAborted: () => aborted, }); + const cancelGatewayQuestionBestEffort = (resolvedBy: string) => { + void cancelPendingAgentQuestionForSession({ + sessionKey: input.sessionKey ?? input.sessionId, + resolvedBy, + }).catch((error: unknown) => { + embeddedAgentLog.warn("failed to cancel copilot gateway question during shutdown", { + error, + }); + }); + }; const activeRunHandle = { kind: "embedded" as const, - queueMessage: async (text: string) => { - if (userInputBridge.handleQueuedMessage(text)) { + // This backend intentionally omits supportsQueueMessageImages, so the reply + // registry rejects attachment turns before this text-only callback runs. + queueMessage: async (text: string, options?: { isInboundUserMessage?: boolean }) => { + if ( + options?.isInboundUserMessage === true && + (await claimPendingAgentQuestionAnswer({ + sessionKey: input.sessionKey ?? input.sessionId, + text, + })) + ) { return; } throw new Error("Copilot runtime is not waiting for user input."); @@ -910,10 +931,12 @@ export async function runCopilotAttempt( isCompacting: () => bridge?.isCompacting() ?? false, sourceReplyDeliveryMode: input.sourceReplyDeliveryMode, cancel: () => { + cancelGatewayQuestionBestEffort("run-cancel"); userInputBridge.cancelPending(); abortActiveSession(); }, abort: () => { + cancelGatewayQuestionBestEffort("run-abort"); userInputBridge.cancelPending(); abortActiveSession(); }, diff --git a/extensions/copilot/src/user-input-bridge.test.ts b/extensions/copilot/src/user-input-bridge.test.ts index 0f1c9a2fc1b6..92d55ed800d7 100644 --- a/extensions/copilot/src/user-input-bridge.test.ts +++ b/extensions/copilot/src/user-input-bridge.test.ts @@ -1,75 +1,126 @@ -// Copilot tests cover SDK ask_user bridge behavior. -import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +// Copilot tests cover SDK ask_user gateway behavior. +import { + claimPendingAgentQuestionAnswer, + type AgentHarnessQuestionGatewayCall, + type EmbeddedRunAttemptParams, +} from "openclaw/plugin-sdk/agent-harness-runtime"; import { describe, expect, it, vi } from "vitest"; import { createCopilotUserInputBridge } from "./user-input-bridge.js"; +type GatewayCallRecord = { method: string; opts: unknown; params: unknown }; + function createParams(): EmbeddedRunAttemptParams { return { sessionId: "session-1", sessionKey: "agent:main:session-1", + agentId: "main", + timeoutMs: 75_000, onBlockReply: vi.fn(), } as unknown as EmbeddedRunAttemptParams; } -function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string { - const onBlockReply = params.onBlockReply; - if (!onBlockReply) { - throw new Error("Expected onBlockReply callback"); - } - const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0]; - if (typeof payload?.text !== "string") { - throw new Error("Expected first block reply text"); - } - return payload.text; +function createGatewayStub() { + const calls: GatewayCallRecord[] = []; + let settleWait: ((value: unknown) => void) | undefined; + const wait = new Promise((resolve) => { + settleWait = resolve; + }); + const call: AgentHarnessQuestionGatewayCall = async (method, opts, params) => { + calls.push({ method, opts, params }); + if (method === "question.request") { + return { id: (params as { id: string }).id, expiresAtMs: Date.now() + 75_000 }; + } + if (method === "question.waitAnswer") { + return await wait; + } + if (method === "question.resolve") { + const resolved = params as { + answers?: { answers: Record }; + cancel?: boolean; + }; + const result = resolved.cancel + ? { status: "cancelled" as const } + : { status: "answered" as const, answers: resolved.answers! }; + settleWait?.(result); + return result; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + return { call, calls }; } describe("Copilot user input bridge", () => { - it("prompts through OpenClaw and resolves the SDK request from the next queued message", async () => { + it("registers, presents, claims, and returns a selected option", async () => { const params = createParams(); - const bridge = createCopilotUserInputBridge({ paramsForRun: params }); - + const gateway = createGatewayStub(); + const bridge = createCopilotUserInputBridge({ + paramsForRun: params, + gatewayCall: gateway.call, + }); const response = bridge.onUserInputRequest( - { - question: "Pick a mode", - choices: ["Fast", "Deep"], - allowFreeform: false, - }, + { question: "Pick a mode", choices: ["Fast", "Deep"], allowFreeform: false }, { sessionId: "sdk-session-1" }, ); + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); + const request = gateway.calls[0]; + if (!request) { + throw new Error("expected question.request"); + } - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - expect(expectFirstBlockReplyText(params)).toContain("Pick a mode"); - expect(bridge.handleQueuedMessage("2")).toBe(true); + expect(request.params).toMatchObject({ + sessionKey: "agent:main:session-1", + agentId: "main", + timeoutMs: 75_000, + questions: [ + expect.objectContaining({ id: "answer", options: [{ label: "Fast" }, { label: "Deep" }] }), + ], + }); + const payload = vi.mocked(params.onBlockReply!).mock.calls[0]![0]; + expect(payload.channelData).toEqual({ + askUser: { questionId: (request.params as { id: string }).id }, + }); + expect(payload.presentationTextMode).toBe("fallback"); + expect(payload.text).toContain("Reply with the number or option text."); + expect(payload.text).not.toContain("your own answer"); + await expect( + claimPendingAgentQuestionAnswer({ sessionKey: params.sessionKey, text: "2" }), + ).resolves.toBe(true); await expect(response).resolves.toEqual({ answer: "Deep", wasFreeform: false }); }); - it("returns free-form answers when Copilot allows them", async () => { + it("supports option-less free-form questions", async () => { const params = createParams(); - const bridge = createCopilotUserInputBridge({ paramsForRun: params }); - + const gateway = createGatewayStub(); + const bridge = createCopilotUserInputBridge({ + paramsForRun: params, + gatewayCall: gateway.call, + }); const response = bridge.onUserInputRequest( - { - question: "Which branch?", - allowFreeform: true, - }, + { question: "Which branch?", allowFreeform: true }, { sessionId: "sdk-session-1" }, ); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - expect(bridge.handleQueuedMessage("fix/harness-parity")).toBe(true); - - await expect(response).resolves.toEqual({ - answer: "fix/harness-parity", - wasFreeform: true, + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); + expect(gateway.calls[0]?.params).toMatchObject({ + questions: [expect.objectContaining({ options: [] })], }); + expect(vi.mocked(params.onBlockReply!).mock.calls[0]![0].presentation).toBeUndefined(); + + await claimPendingAgentQuestionAnswer({ + sessionKey: params.sessionKey, + text: "fix/harness-parity", + }); + await expect(response).resolves.toEqual({ answer: "fix/harness-parity", wasFreeform: true }); }); - it("escapes SDK-controlled prompt text before channel delivery", async () => { + it("escapes SDK-controlled prompt text before delivery", async () => { const params = createParams(); - const bridge = createCopilotUserInputBridge({ paramsForRun: params }); - - void bridge.onUserInputRequest( + const gateway = createGatewayStub(); + const bridge = createCopilotUserInputBridge({ + paramsForRun: params, + gatewayCall: gateway.call, + }); + const response = bridge.onUserInputRequest( { question: "Pick [trusted](https://evil) <@U123> @here\u202e", choices: ["One @everyone", "Two `code`"], @@ -77,9 +128,8 @@ describe("Copilot user input bridge", () => { }, { sessionId: "sdk-session-1" }, ); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - const text = expectFirstBlockReplyText(params); + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); + const text = vi.mocked(params.onBlockReply!).mock.calls[0]![0].text ?? ""; expect(text).not.toContain("@here"); expect(text).not.toContain("@everyone"); expect(text).not.toContain("<@U123>"); @@ -87,35 +137,65 @@ describe("Copilot user input bridge", () => { expect(text).not.toContain("`code`"); expect(text).toContain("\uff20here"); expect(text).toContain("\uff3btrusted\uff3d"); + const presentation = vi.mocked(params.onBlockReply!).mock.calls[0]![0].presentation; + const visiblePresentation = presentation?.blocks.map((block) => + block.type === "text" + ? block.text + : block.type === "buttons" + ? block.buttons.map((button) => button.label).join(" ") + : "", + ); + expect(JSON.stringify(visiblePresentation)).not.toContain("@here"); + expect(JSON.stringify(visiblePresentation)).not.toContain("@everyone"); + expect(JSON.stringify(visiblePresentation)).not.toContain("<@U123>"); + expect(JSON.stringify(visiblePresentation)).not.toContain("[trusted](https://evil)"); + bridge.cancelPending(); + await response; }); - it("rejects queued messages when no ask_user request is pending", () => { - const bridge = createCopilotUserInputBridge({ paramsForRun: createParams() }); - - expect(bridge.handleQueuedMessage("late")).toBe(false); - }); - - it("resolves pending requests with an empty answer when aborted", async () => { + it("cancels the gateway record and returns an empty answer on abort", async () => { const params = createParams(); const controller = new AbortController(); + const gateway = createGatewayStub(); const bridge = createCopilotUserInputBridge({ paramsForRun: params, signal: controller.signal, + gatewayCall: gateway.call, }); - const response = bridge.onUserInputRequest( - { - question: "Continue?", - choices: ["Yes", "No"], - allowFreeform: false, - }, + { question: "Continue?", choices: ["Yes", "No"], allowFreeform: false }, { sessionId: "sdk-session-1" }, ); - - await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); controller.abort(); await expect(response).resolves.toEqual({ answer: "", wasFreeform: true }); - expect(bridge.handleQueuedMessage("1")).toBe(false); + expect(gateway.calls).toContainEqual( + expect.objectContaining({ + method: "question.resolve", + params: expect.objectContaining({ cancel: true, resolvedBy: "run-abort" }), + }), + ); + }); + + it("does not register a gateway question after the run already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const params = createParams(); + const gateway = createGatewayStub(); + const bridge = createCopilotUserInputBridge({ + paramsForRun: params, + signal: controller.signal, + gatewayCall: gateway.call, + }); + + await expect( + bridge.onUserInputRequest( + { question: "Continue?", choices: ["Yes", "No"], allowFreeform: false }, + { sessionId: "sdk-session-1" }, + ), + ).resolves.toEqual({ answer: "", wasFreeform: true }); + expect(gateway.calls).toEqual([]); + expect(params.onBlockReply).not.toHaveBeenCalled(); }); }); diff --git a/extensions/copilot/src/user-input-bridge.ts b/extensions/copilot/src/user-input-bridge.ts index 684bb898ed7b..2b8e1543c4f1 100644 --- a/extensions/copilot/src/user-input-bridge.ts +++ b/extensions/copilot/src/user-input-bridge.ts @@ -1,77 +1,78 @@ import type { SessionConfig } from "@github/copilot-sdk"; import { - buildAgentHarnessUserInputAnswers, - deliverAgentHarnessUserInputPrompt, + callGatewayTool, embeddedAgentLog, + runAgentHarnessGatewayQuestion, + type AgentHarnessQuestionGatewayCall, type AgentHarnessUserInputQuestion, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; -type PendingCopilotUserInput = { - question: AgentHarnessUserInputQuestion; - resolve: (value: CopilotUserInputResponse) => void; - cleanup: () => void; -}; - type CopilotUserInputHandler = NonNullable; type CopilotUserInputRequest = Parameters[0]; type CopilotUserInputResponse = Awaited>; type CopilotUserInputBridge = { onUserInputRequest: CopilotUserInputHandler; - handleQueuedMessage: (text: string) => boolean; cancelPending: () => void; }; const COPILOT_USER_INPUT_QUESTION_ID = "answer"; +const DEFAULT_USER_INPUT_TIMEOUT_MS = 15 * 60_000; export function createCopilotUserInputBridge(params: { paramsForRun: EmbeddedRunAttemptParams; signal?: AbortSignal; + gatewayCall?: AgentHarnessQuestionGatewayCall; }): CopilotUserInputBridge { - let pending: PendingCopilotUserInput | undefined; - - const resolvePending = (value: CopilotUserInputResponse) => { - const current = pending; - if (!current) { - return; - } - pending = undefined; - current.cleanup(); - current.resolve(value); - }; + let pending: AbortController | undefined; + const gatewayCall = params.gatewayCall ?? callGatewayTool; return { - onUserInputRequest(request) { - const question = toQuestion(request); - resolvePending(emptyCopilotUserInputResponse()); - return new Promise((resolve) => { - const abortListener = () => resolvePending(emptyCopilotUserInputResponse()); - const cleanup = () => params.signal?.removeEventListener("abort", abortListener); - pending = { question, resolve, cleanup }; - params.signal?.addEventListener("abort", abortListener, { once: true }); - if (params.signal?.aborted) { - resolvePending(emptyCopilotUserInputResponse()); - return; - } - void deliverAgentHarnessUserInputPrompt(params.paramsForRun, [question], { - intro: "Copilot needs input:", - formatText: formatCopilotDisplayText, - }).catch((error: unknown) => { - embeddedAgentLog.warn("failed to deliver copilot user input prompt", { error }); - }); - }); - }, - handleQueuedMessage(text) { - const current = pending; - if (!current) { - return false; + async onUserInputRequest(request) { + pending?.abort(new Error("Copilot user input request replaced")); + const abort = new AbortController(); + pending = abort; + const abortFromRun = () => abort.abort(params.signal?.reason); + params.signal?.addEventListener("abort", abortFromRun, { once: true }); + if (params.signal?.aborted) { + abortFromRun(); + } + try { + const question = toQuestion(request); + const result = await runAgentHarnessGatewayQuestion({ + questions: [question], + sessionKey: params.paramsForRun.sessionKey ?? params.paramsForRun.sessionId, + agentId: params.paramsForRun.agentId, + timeoutMs: params.paramsForRun.timeoutMs ?? DEFAULT_USER_INPUT_TIMEOUT_MS, + gatewayCall, + delivery: params.paramsForRun, + promptOptions: { + intro: "Copilot needs input:", + formatText: formatCopilotDisplayText, + }, + signal: abort.signal, + }); + if (result.status !== "answered") { + return emptyCopilotUserInputResponse(); + } + const selected = result.answers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? ""; + return { + answer: selected, + wasFreeform: !isChoiceAnswer(question, selected), + }; + } catch (error) { + embeddedAgentLog.warn("failed to bridge copilot user input through gateway", { error }); + return emptyCopilotUserInputResponse(); + } finally { + params.signal?.removeEventListener("abort", abortFromRun); + if (pending === abort) { + pending = undefined; + } } - resolvePending(buildCopilotUserInputResponse(current.question, text)); - return true; }, cancelPending() { - resolvePending(emptyCopilotUserInputResponse()); + pending?.abort(new Error("Copilot user input request cancelled")); }, }; } @@ -79,7 +80,7 @@ export function createCopilotUserInputBridge(params: { function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQuestion { return { id: COPILOT_USER_INPUT_QUESTION_ID, - header: "Copilot needs input", + header: "Copilot", question: request.question, isOther: request.allowFreeform !== false, isSecret: false, @@ -90,18 +91,6 @@ function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQues }; } -function buildCopilotUserInputResponse( - question: AgentHarnessUserInputQuestion, - inputText: string, -): CopilotUserInputResponse { - const rawAnswers = buildAgentHarnessUserInputAnswers([question], inputText); - const selected = rawAnswers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? ""; - return { - answer: selected, - wasFreeform: !isChoiceAnswer(question, selected), - }; -} - function emptyCopilotUserInputResponse(): CopilotUserInputResponse { return { answer: "", wasFreeform: true }; } diff --git a/extensions/discord/src/monitor/questions.ts b/extensions/discord/src/monitor/questions.ts index 66666eab611a..c6abedebe4ed 100644 --- a/extensions/discord/src/monitor/questions.ts +++ b/extensions/discord/src/monitor/questions.ts @@ -1,9 +1,6 @@ // Discord ask_user component dispatch and ephemeral feedback. import { ButtonStyle } from "discord-api-types/v10"; -import { - resolveQuestionOverGateway, - type ResolveQuestionOverGatewayParams, -} from "openclaw/plugin-sdk/question-gateway-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import { Button, type ButtonInteraction, type ComponentData } from "../internal/discord.js"; import { parseDiscordQuestionData } from "../question-custom-id.js"; import { @@ -11,9 +8,10 @@ import { resolveAuthorizedComponentInteraction, } from "./agent-components-helpers.js"; +type ResolveQuestionParams = Parameters[0]; type QuestionResolver = ( - params: ResolveQuestionOverGatewayParams, -) => ReturnType; + params: ResolveQuestionParams, +) => ReturnType; class QuestionButton extends Button { override label = "question"; @@ -22,7 +20,7 @@ class QuestionButton extends Button { constructor( private readonly ctx: { - cfg: ResolveQuestionOverGatewayParams["cfg"]; + cfg: ResolveQuestionParams["cfg"]; accountId: string; resolveQuestion: QuestionResolver; authorizeQuestion: (interaction: ButtonInteraction) => Promise; @@ -73,7 +71,7 @@ class QuestionButton extends Button { } export function createDiscordQuestionButton(params: { - cfg: ResolveQuestionOverGatewayParams["cfg"]; + cfg: ResolveQuestionParams["cfg"]; accountId: string; authContext?: AgentComponentContext; authorizeQuestion?: (interaction: ButtonInteraction) => Promise; @@ -83,7 +81,7 @@ export function createDiscordQuestionButton(params: { return new QuestionButton({ cfg: params.cfg, accountId: params.accountId, - resolveQuestion: params.resolveQuestion ?? resolveQuestionOverGateway, + resolveQuestion: params.resolveQuestion ?? questionGatewayRuntime.resolveOption, authorizeQuestion: params.authorizeQuestion ?? (async (interaction) => diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index fa4b3f586dea..b5d633b22550 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -8,11 +8,13 @@ import { } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import { normalizeOptionalString, normalizeOptionalStringifiedId, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { formatDiscordApprovalDisplayValue } from "./approval-message-safety.js"; import { chunkDiscordTextWithMode } from "./chunk.js"; import { notifyDiscordInboundEventOutboundPayloadSuccess } from "./inbound-event-delivery.js"; import { isLikelyDiscordVideoMedia } from "./media-detection.js"; @@ -22,6 +24,7 @@ import { normalizeDiscordApprovalPayload } from "./outbound-approval.js"; import { buildDiscordPresentationPayload, DISCORD_PRESENTATION_CAPABILITIES, + resolveDiscordComponentSpec, } from "./outbound-components.js"; import { sendDiscordOutboundPayload } from "./outbound-payload.js"; import { @@ -51,6 +54,9 @@ function stripDiscordInternalRuntimeScaffolding(text: string): string { const loadDiscordThreadBindings = createLazyRuntimeModule( () => import("./monitor/thread-bindings.js"), ); +const loadDiscordComponentSendRuntime = createLazyRuntimeModule( + () => import("./send.components.js"), +); function resolveDiscordWebhookIdentity(params: { identity?: OutboundIdentity; @@ -291,12 +297,42 @@ export const discordOutbound: ChannelOutboundAdapter = { cfg, }), }), - afterDeliverPayload: async ({ target, payload }) => { + afterDeliverPayload: async ({ cfg, target, payload, results }) => { notifyDiscordInboundEventOutboundPayloadSuccess({ payload, to: resolveDiscordOutboundTarget({ to: target.to, threadId: target.threadId }), accountId: target.accountId, }); + const questionId = questionGatewayRuntime.readAskUserQuestionId(payload); + const result = results.find( + (candidate) => candidate.channel === "discord" && candidate.messageId, + ); + const componentSpec = questionId ? await resolveDiscordComponentSpec(payload) : undefined; + if (questionId && result && componentSpec) { + const to = resolveDiscordOutboundTarget({ to: target.to, threadId: target.threadId }); + questionGatewayRuntime.registerChannelDelivery({ + questionId, + deliveryId: `discord:${target.accountId ?? "default"}:${result.channelId ?? to}:${result.messageId}`, + finalize: async (statusLine) => { + const { editDiscordComponentMessage } = await loadDiscordComponentSendRuntime(); + await editDiscordComponentMessage( + to, + result.messageId, + { + ...componentSpec, + blocks: [ + ...(componentSpec.blocks ?? []).filter((block) => block.type !== "actions"), + // Same markdown-inert display escaping approvals use; raw + // option labels must not become live Discord markup/mentions. + { type: "text", text: `-# ${formatDiscordApprovalDisplayValue(statusLine)}` }, + ], + modal: undefined, + }, + { cfg, accountId: target.accountId ?? undefined }, + ); + }, + }); + } const threadId = normalizeOptionalStringifiedId(target.threadId); if (!threadId) { return; diff --git a/extensions/discord/src/question-finalization.test.ts b/extensions/discord/src/question-finalization.test.ts new file mode 100644 index 000000000000..9bebf3203421 --- /dev/null +++ b/extensions/discord/src/question-finalization.test.ts @@ -0,0 +1,62 @@ +// Covers Discord question delivery capture and component final edit. +import { describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + edit: vi.fn(), + registration: undefined as + | { finalize: (statusLine: string) => void | Promise; deliveryId: string } + | undefined, +})); +vi.mock("openclaw/plugin-sdk/question-gateway-runtime", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + questionGatewayRuntime: { + ...original.questionGatewayRuntime, + registerChannelDelivery: (registration: typeof hoisted.registration) => { + hoisted.registration = registration; + }, + }, + }; +}); +vi.mock("./send.components.js", () => ({ editDiscordComponentMessage: hoisted.edit })); + +import { discordOutbound } from "./outbound-adapter.js"; + +describe("Discord question finalization", () => { + it("removes action rows and appends terminal status", async () => { + await discordOutbound.afterDeliverPayload?.({ + cfg: {}, + target: { channel: "discord", to: "channel:123", accountId: "default" }, + payload: { + channelData: { + askUser: { questionId: "ask_0123456789abcdef0123456789abcdef" }, + discord: { + presentationComponents: { + blocks: [ + { type: "text", text: "Pick one" }, + { type: "actions", buttons: [{ label: "One", internalCustomId: "q" }] }, + ], + }, + }, + }, + }, + results: [{ channel: "discord", messageId: "55", channelId: "123" }], + }); + + await hoisted.registration?.finalize("Expired"); + expect(hoisted.edit).toHaveBeenCalledWith( + "channel:123", + "55", + { + blocks: [ + { type: "text", text: "Pick one" }, + { type: "text", text: "-# Expired" }, + ], + modal: undefined, + }, + { cfg: {}, accountId: "default" }, + ); + }); +}); diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 39bfac549df0..2597f91419af 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -11,6 +11,7 @@ import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { createComputedAccountStatusAdapter, @@ -90,6 +91,9 @@ function toIMessageMessageSendResult( const loadIMessageApprovalReactionsModule = createLazyRuntimeModule( () => import("./approval-reactions.js"), ); +const loadIMessageQuestionReactionsModule = createLazyRuntimeModule( + () => import("./question-reactions.js"), +); async function prepareForwardedIMessageApprovalPayload(params: { payload: Parameters>[0]["payload"]; @@ -110,6 +114,14 @@ async function registerDeliveredIMessageApprovalPayload( cfg: params.cfg, accountId: params.target.accountId, }).accountId; + ( + await loadIMessageQuestionReactionsModule() + ).registerIMessageQuestionReactionTargetForDeliveredPayload({ + accountId, + target: params.target, + payload: params.payload, + results: params.results, + }); ( await loadIMessageApprovalReactionsModule() ).registerIMessageApprovalReactionTargetForDeliveredPayload({ @@ -409,6 +421,8 @@ export const imessagePlugin: ChannelPlugin + questionGatewayRuntime.prepareReactionPayloadForDelivery({ payload, presentation }), afterDeliverPayload: async (params) => await registerDeliveredIMessageApprovalPayload(params), deliveryCapabilities: { diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 4793602e8230..4de91a99341a 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -61,6 +61,10 @@ import { imessageRpcSupportsMethod, probeIMessage, } from "../probe.js"; +import { + hasIMessageQuestionReactionTarget, + maybeResolveIMessageQuestionReaction, +} from "../question-reactions.js"; import { sendMessageIMessage } from "../send.js"; import { normalizeIMessageHandle } from "../targets.js"; import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; @@ -925,6 +929,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P process.env, accountInfo.accountId, ).catch(() => []); + const isQuestionReaction = hasIMessageQuestionReactionTarget({ + accountId: accountInfo.accountId, + message, + bodyText, + }); const decision = await resolveIMessageInboundDecision({ cfg, accountId: accountInfo.accountId, @@ -942,7 +951,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P groupHistories, echoCache: sentMessageCache, selfChatCache, - reactionNotifications: imessageCfg.reactionNotifications, + reactionNotifications: isQuestionReaction ? "all" : imessageCfg.reactionNotifications, logVerbose, }); @@ -1044,6 +1053,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P } if (decision.kind === "reaction") { + if ( + await maybeResolveIMessageQuestionReaction({ + cfg, + accountId: accountInfo.accountId, + message, + bodyText, + senderId: decision.senderNormalized, + logDebug: logVerbose, + }) + ) { + return; + } enqueueIMessageReactionSystemEvent({ decision, runtime, logVerbose }); return; } diff --git a/extensions/imessage/src/question-reactions.test.ts b/extensions/imessage/src/question-reactions.test.ts new file mode 100644 index 000000000000..b2635808a71e --- /dev/null +++ b/extensions/imessage/src/question-reactions.test.ts @@ -0,0 +1,117 @@ +// Covers iMessage delivery binding and numbered-reaction dispatch. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ resolve: vi.fn() })); +vi.mock("openclaw/plugin-sdk/question-gateway-runtime", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + questionGatewayRuntime: { + ...original.questionGatewayRuntime, + resolveReaction: hoisted.resolve, + }, + }; +}); + +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; +import { + clearIMessageQuestionReactionTargetsForTest, + hasIMessageQuestionReactionTarget, + maybeResolveIMessageQuestionReaction, + registerIMessageQuestionReactionTargetForDeliveredPayload, +} from "./question-reactions.js"; + +const questionId = "ask_0123456789abcdef0123456789abcdef"; + +function buildPayload() { + const presentation = { + blocks: [ + { type: "text" as const, text: "Pick one" }, + { + type: "buttons" as const, + buttons: ["One", "Two"].map((label) => ({ + label, + action: { type: "question" as const, questionId, optionValue: label }, + })), + }, + ], + }; + return questionGatewayRuntime.prepareReactionPayloadForDelivery({ + payload: { presentation, channelData: { askUser: { questionId } } }, + presentation, + }); +} + +describe("iMessage question reactions", () => { + beforeEach(() => { + clearIMessageQuestionReactionTargetsForTest(); + hoisted.resolve.mockReset().mockResolvedValue({ + status: "answered", + questionId: "choice", + optionValue: "Two", + }); + }); + + it("recognizes a stable GUID and consumes a stale duplicate", async () => { + expect( + registerIMessageQuestionReactionTargetForDeliveredPayload({ + accountId: "default", + target: { channel: "imessage" }, + payload: buildPayload()!, + results: [ + { + channel: "imessage", + messageId: "42", + meta: { imessageMessageGuid: "p:0/GUID-1" }, + }, + ], + }), + ).toBe(true); + const message = { + sender: "+15550002222", + is_reaction: true, + is_reaction_add: true, + reaction_emoji: "2️⃣", + reacted_to_guid: "GUID-1", + }; + expect(hasIMessageQuestionReactionTarget({ accountId: "default", message, bodyText: "" })).toBe( + true, + ); + expect( + hasIMessageQuestionReactionTarget({ + accountId: "default", + message: { ...message, reaction_emoji: "❤️" }, + bodyText: "", + }), + ).toBe(false); + expect( + hasIMessageQuestionReactionTarget({ + accountId: "default", + message: { ...message, is_reaction_add: false }, + bodyText: "", + }), + ).toBe(false); + const params = { + cfg: {}, + accountId: "default", + message, + bodyText: "", + senderId: "+15550002222", + logDebug: vi.fn(), + }; + + await expect( + maybeResolveIMessageQuestionReaction({ + ...params, + message: { ...message, reaction_emoji: "4️⃣" }, + }), + ).resolves.toBe(true); + await expect(maybeResolveIMessageQuestionReaction(params)).resolves.toBe(true); + await expect(maybeResolveIMessageQuestionReaction(params)).resolves.toBe(true); + expect(hoisted.resolve).toHaveBeenCalledOnce(); + expect(hoisted.resolve).toHaveBeenCalledWith( + expect.objectContaining({ questionId, optionValue: "Two", senderId: "+15550002222" }), + ); + }); +}); diff --git a/extensions/imessage/src/question-reactions.ts b/extensions/imessage/src/question-reactions.ts new file mode 100644 index 000000000000..d3cd54d19d89 --- /dev/null +++ b/extensions/imessage/src/question-reactions.ts @@ -0,0 +1,190 @@ +// iMessage transport binding for numbered ask_user reactions. +import type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-result"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveIMessageReactionContext } from "./monitor/reaction-context.js"; +import type { IMessagePayload } from "./monitor/types.js"; + +const TARGET_TTL_MS = 24 * 60 * 60 * 1_000; + +type IMessageQuestionReactionTarget = { + questionId: string; + optionValues: string[]; + terminal: boolean; + expiresAtMs: number; + cleanupTimer: ReturnType; +}; + +const targets = new Map(); + +function storeTarget(key: string, binding: { questionId: string; optionValues: string[] }): void { + const existing = targets.get(key); + if (existing) { + clearTimeout(existing.cleanupTimer); + } + const target: IMessageQuestionReactionTarget = { + ...binding, + terminal: false, + expiresAtMs: Date.now() + TARGET_TTL_MS, + cleanupTimer: setTimeout(() => { + if (targets.get(key) === target) { + targets.delete(key); + } + }, TARGET_TTL_MS), + }; + target.cleanupTimer.unref?.(); + targets.set(key, target); + questionGatewayRuntime.registerChannelDelivery({ + questionId: binding.questionId, + deliveryId: `imessage-reaction:${key}`, + finalize: () => { + target.terminal = true; + }, + }); +} + +function normalizeGuid(value: string): string { + return value.trim().replace(/^p:\d+\//iu, ""); +} + +function buildKey(accountId: string, messageGuid: string): string | null { + const account = accountId.trim(); + const guid = normalizeGuid(messageGuid); + return account && guid ? `${account}:${guid}` : null; +} + +function reactionCandidates( + message: IMessagePayload, + bodyText: string, +): { + action: "added" | "removed"; + emoji: string; + guids: string[]; +} | null { + const reaction = resolveIMessageReactionContext(message, bodyText); + if (!reaction) { + return null; + } + const guids = Array.from( + new Set( + [...(reaction.targetGuids ?? []), reaction.targetGuid ?? ""] + .map(normalizeGuid) + .filter(Boolean), + ), + ); + return guids.length > 0 ? { action: reaction.action, emoji: reaction.emoji, guids } : null; +} + +export function registerIMessageQuestionReactionTargetForDeliveredPayload(params: { + accountId: string; + target: { channel: string }; + payload: ReplyPayload; + results: readonly OutboundDeliveryResult[]; +}): boolean { + const binding = questionGatewayRuntime.readReactionBinding(params.payload); + if (params.target.channel !== "imessage" || !binding) { + return false; + } + let registered = false; + for (const result of params.results) { + if (result.channel !== "imessage") { + continue; + } + const guid = + typeof result.meta?.imessageMessageGuid === "string" + ? result.meta.imessageMessageGuid + : result.messageId; + const key = buildKey(params.accountId, guid); + if (!key || /^\d+$/u.test(normalizeGuid(guid))) { + continue; + } + storeTarget(key, binding); + registered = true; + } + return registered; +} + +export function hasIMessageQuestionReactionTarget(params: { + accountId: string; + message: IMessagePayload; + bodyText: string; +}): boolean { + const reaction = reactionCandidates(params.message, params.bodyText); + if ( + !reaction || + reaction.action !== "added" || + questionGatewayRuntime.resolveReactionIndex(reaction.emoji) === undefined + ) { + return false; + } + return reaction.guids.some((guid) => { + const key = buildKey(params.accountId, guid); + return key ? targets.has(key) : false; + }); +} + +export async function maybeResolveIMessageQuestionReaction(params: { + cfg: OpenClawConfig; + accountId: string; + message: IMessagePayload; + bodyText: string; + senderId: string; + gatewayUrl?: string; + logDebug?: (message: string) => void; +}): Promise { + const reaction = reactionCandidates(params.message, params.bodyText); + const optionIndex = reaction + ? questionGatewayRuntime.resolveReactionIndex(reaction.emoji) + : undefined; + if (!reaction || reaction.action === "removed" || optionIndex === undefined) { + return false; + } + let target: IMessageQuestionReactionTarget | undefined; + for (const guid of reaction.guids) { + const key = buildKey(params.accountId, guid); + target = key ? targets.get(key) : undefined; + if (target) { + break; + } + } + if (!target) { + return false; + } + if (target.expiresAtMs <= Date.now() || target.terminal) { + target.terminal = true; + params.logDebug?.(`imessage: stale question reaction ignored id=${target.questionId}`); + return true; + } + const optionValue = target.optionValues[optionIndex]; + if (!optionValue) { + params.logDebug?.(`imessage: out-of-range question reaction ignored id=${target.questionId}`); + return true; + } + try { + const result = await questionGatewayRuntime.resolveReaction({ + cfg: params.cfg, + questionId: target.questionId, + optionValue, + senderId: params.senderId, + gatewayUrl: params.gatewayUrl, + clientDisplayName: `iMessage question (${params.senderId})`, + }); + target.terminal = result?.status === "answered" || result?.status === "already-terminal"; + if (result?.status === "already-terminal") { + params.logDebug?.(`imessage: stale question reaction ignored id=${target.questionId}`); + } + } catch (error) { + params.logDebug?.( + `imessage: question reaction failed id=${target.questionId}: ${String(error)}`, + ); + } + return true; +} + +export function clearIMessageQuestionReactionTargetsForTest(): void { + for (const target of targets.values()) { + clearTimeout(target.cleanupTimer); + } + targets.clear(); +} diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index fa94dc7b68e2..32fc814920cf 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -14,6 +14,7 @@ import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import { chunkText, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { @@ -415,6 +416,16 @@ async function registerDeliveredSignalApprovalPayloadForReactions( if (!targetAuthor && !targetAuthorUuid) { return; } + const { registerSignalQuestionReactionTargetForDeliveredPayload } = + await import("./question-reactions.js"); + registerSignalQuestionReactionTargetForDeliveredPayload({ + cfg: params.cfg, + target: { ...params.target, accountId: account.accountId }, + payload: params.payload, + results: params.results, + targetAuthor, + targetAuthorUuid, + }); const { registerSignalApprovalReactionTargetForDeliveredPayload } = await loadSignalApprovalReactionsModule(); registerSignalApprovalReactionTargetForDeliveredPayload({ @@ -442,6 +453,13 @@ async function renderSignalApprovalPayloadForReactions( const { addSignalApprovalReactionHintToStructuredPayload } = await loadSignalApprovalReactionsModule(); const payload = materializeSignalPresentationFallback(params.payload, params.presentation); + const questionPayload = questionGatewayRuntime.prepareReactionPayloadForDelivery({ + payload: params.payload, + presentation: params.presentation, + }); + if (questionPayload) { + return questionPayload; + } return addSignalApprovalReactionHintToStructuredPayload({ cfg: params.ctx.cfg, accountId: params.ctx.accountId ?? undefined, diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index e4ad829ef2d7..d489c5baf94a 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -44,10 +44,7 @@ import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime"; import { resolveSignalAccount, resolveSignalReplyToMode } from "./accounts.js"; import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js"; -import { - addSignalApprovalReactionHintToStructuredPayload, - registerSignalApprovalReactionTargetForDeliveredPayload, -} from "./approval-reactions.js"; +import { addSignalApprovalReactionHintToStructuredPayload } from "./approval-reactions.js"; import { signalRpcRequest, signalCheck } from "./client-adapter.js"; import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; @@ -59,6 +56,7 @@ import type { SignalReactionTarget, } from "./monitor/event-handler.types.js"; import { materializeSignalPresentationFallback } from "./presentation-fallback.js"; +import { registerSignalReactionTargetsForDeliveredPayload } from "./reaction-targets.js"; import { sendMessageSignal } from "./send.js"; import { startSignalIngressMonitor, type SignalIngressMonitor } from "./signal-ingress.js"; import { runSignalSseLoop } from "./sse-reconnect.js"; @@ -435,7 +433,7 @@ export async function deliverReplies(params: { }, }); if (delivered !== "empty") { - registerSignalApprovalReactionTargetForDeliveredPayload({ + registerSignalReactionTargetsForDeliveredPayload({ cfg: params.cfg, target: { channel: "signal", diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index ae03b08e1011..b03396fa43e8 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -75,6 +75,7 @@ import { type SignalSender, } from "../identity.js"; import { normalizeSignalMessagingTarget } from "../normalize.js"; +import { maybeResolveSignalQuestionReaction } from "../question-reactions.js"; import { resolveSignalReactionLevel } from "../reaction-level.js"; import { registerSignalReplyContext } from "../reply-authors.js"; import { @@ -895,6 +896,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { ); return true; } + if ( + conversationKey && + (await maybeResolveSignalQuestionReaction({ + cfg: deps.cfg, + accountId: deps.accountId, + conversationKey, + messageId, + reactionKey: emojiLabel, + isRemove: Boolean(params.reaction.isRemove), + actorId: formatSignalSenderId(params.sender), + targetAuthor: params.reaction.targetAuthor, + targetAuthorUuid: params.reaction.targetAuthorUuid, + logDebug: logVerbose, + })) + ) { + return true; + } const targets = deps.resolveSignalReactionTargets(params.reaction); const shouldNotify = deps.shouldEmitSignalReactionNotification({ mode: deps.reactionMode, diff --git a/extensions/signal/src/presentation-fallback.ts b/extensions/signal/src/presentation-fallback.ts index 07196fb4c8bb..20423bbd234c 100644 --- a/extensions/signal/src/presentation-fallback.ts +++ b/extensions/signal/src/presentation-fallback.ts @@ -6,6 +6,7 @@ import { renderMessagePresentationFallbackText, type MessagePresentation, } from "openclaw/plugin-sdk/interactive-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; /** Materialize presentation content once before Signal's text-only delivery funnels. */ @@ -13,6 +14,13 @@ export function materializeSignalPresentationFallback( payload: ReplyPayload, presentationOverride?: MessagePresentation, ): ReplyPayload { + const reactionPayload = questionGatewayRuntime.prepareReactionPayloadForDelivery({ + payload, + presentation: presentationOverride ?? payload.presentation, + }); + if (reactionPayload) { + return reactionPayload; + } const presentation = presentationOverride ?? normalizeMessagePresentation(payload.presentation); if (!presentation) { return payload; diff --git a/extensions/signal/src/question-reactions.test.ts b/extensions/signal/src/question-reactions.test.ts new file mode 100644 index 000000000000..a78845f400a7 --- /dev/null +++ b/extensions/signal/src/question-reactions.test.ts @@ -0,0 +1,92 @@ +// Covers Signal delivery binding and numbered-reaction dispatch. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ resolve: vi.fn() })); +vi.mock("openclaw/plugin-sdk/question-gateway-runtime", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + questionGatewayRuntime: { + ...original.questionGatewayRuntime, + resolveReaction: hoisted.resolve, + }, + }; +}); + +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; +import { + maybeResolveSignalQuestionReaction, + registerSignalQuestionReactionTargetForDeliveredPayload, +} from "./question-reactions.js"; + +const questionId = "ask_0123456789abcdef0123456789abcdef"; + +function buildPayload() { + const presentation = { + blocks: [ + { type: "text" as const, text: "Pick one" }, + { + type: "buttons" as const, + buttons: ["One", "Two"].map((label) => ({ + label, + action: { type: "question" as const, questionId, optionValue: label }, + })), + }, + ], + }; + return questionGatewayRuntime.prepareReactionPayloadForDelivery({ + payload: { presentation, channelData: { askUser: { questionId } } }, + presentation, + }); +} + +describe("Signal question reactions", () => { + beforeEach(() => { + hoisted.resolve.mockReset().mockResolvedValue({ + status: "answered", + questionId: "choice", + optionValue: "One", + }); + }); + + it("matches the bot-authored target and ignores a duplicate", async () => { + const payload = buildPayload(); + expect( + registerSignalQuestionReactionTargetForDeliveredPayload({ + cfg: {}, + target: { channel: "signal", to: "+15550002222", accountId: "default" }, + payload: payload!, + results: [{ channel: "signal", messageId: "1000" }], + targetAuthor: "+15550001111", + }), + ).toBe(true); + const params = { + cfg: {}, + accountId: "default", + conversationKey: "+15550002222", + messageId: "1000", + reactionKey: "1️⃣", + isRemove: false, + actorId: "+15550002222", + targetAuthor: "+15550001111", + logDebug: vi.fn(), + }; + + await expect(maybeResolveSignalQuestionReaction({ ...params, isRemove: true })).resolves.toBe( + false, + ); + await expect( + maybeResolveSignalQuestionReaction({ ...params, reactionKey: "4️⃣" }), + ).resolves.toBe(true); + await expect(maybeResolveSignalQuestionReaction(params)).resolves.toBe(true); + await expect(maybeResolveSignalQuestionReaction(params)).resolves.toBe(true); + expect(hoisted.resolve).toHaveBeenCalledOnce(); + expect(hoisted.resolve).toHaveBeenCalledWith( + expect.objectContaining({ questionId, optionValue: "One", senderId: "+15550002222" }), + ); + expect(params.logDebug).toHaveBeenCalledWith( + expect.stringContaining("stale question reaction ignored"), + ); + }); +}); diff --git a/extensions/signal/src/question-reactions.ts b/extensions/signal/src/question-reactions.ts new file mode 100644 index 000000000000..73365ee67222 --- /dev/null +++ b/extensions/signal/src/question-reactions.ts @@ -0,0 +1,168 @@ +// Signal transport binding for numbered ask_user reactions. +import type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-result"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { resolveSignalTarget } from "./aliases.js"; +import { + resolveSignalApprovalConversationKey, + resolveSignalApprovalTargetAuthorKeys, +} from "./approval-reactions.js"; + +const TARGET_TTL_MS = 24 * 60 * 60 * 1_000; + +type SignalQuestionReactionTarget = { + questionId: string; + optionValues: string[]; + targetAuthorKeys: string[]; + terminal: boolean; + expiresAtMs: number; + cleanupTimer: ReturnType; +}; + +const targets = new Map(); + +function storeTarget( + key: string, + binding: { questionId: string; optionValues: string[] }, + targetAuthorKeys: string[], +): void { + const existing = targets.get(key); + if (existing) { + clearTimeout(existing.cleanupTimer); + } + const target: SignalQuestionReactionTarget = { + ...binding, + targetAuthorKeys, + terminal: false, + expiresAtMs: Date.now() + TARGET_TTL_MS, + cleanupTimer: setTimeout(() => { + if (targets.get(key) === target) { + targets.delete(key); + } + }, TARGET_TTL_MS), + }; + target.cleanupTimer.unref?.(); + targets.set(key, target); + questionGatewayRuntime.registerChannelDelivery({ + questionId: binding.questionId, + deliveryId: `signal-reaction:${key}`, + finalize: () => { + target.terminal = true; + }, + }); +} + +function buildKey(accountId: string, conversationKey: string, messageId: string): string | null { + const values = [accountId, conversationKey, messageId].map((value) => value.trim()); + return values.every(Boolean) ? values.join(":") : null; +} + +function resolveConversationKey(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}): string | null { + try { + return ( + resolveSignalTarget({ + cfg: params.cfg, + accountId: params.accountId, + input: params.to, + })?.to ?? resolveSignalApprovalConversationKey(params.to) + ); + } catch { + return resolveSignalApprovalConversationKey(params.to); + } +} + +export function registerSignalQuestionReactionTargetForDeliveredPayload(params: { + cfg: OpenClawConfig; + target: { channel: string; to: string; accountId?: string | null }; + payload: ReplyPayload; + results: readonly OutboundDeliveryResult[]; + targetAuthor?: string | null; + targetAuthorUuid?: string | null; +}): boolean { + const binding = questionGatewayRuntime.readReactionBinding(params.payload); + if (params.target.channel !== "signal" || !binding) { + return false; + } + const conversationKey = resolveConversationKey({ cfg: params.cfg, ...params.target }); + const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params); + if (!conversationKey || targetAuthorKeys.length === 0) { + return false; + } + const accountId = normalizeAccountId(params.target.accountId ?? undefined); + let registered = false; + for (const result of params.results) { + const messageId = result.channel === "signal" ? result.messageId.trim() : ""; + const key = + messageId && messageId !== "unknown" ? buildKey(accountId, conversationKey, messageId) : null; + if (!key) { + continue; + } + storeTarget(key, binding, targetAuthorKeys); + registered = true; + } + return registered; +} + +export async function maybeResolveSignalQuestionReaction(params: { + cfg: OpenClawConfig; + accountId: string; + conversationKey: string; + messageId: string; + reactionKey: string; + isRemove: boolean; + actorId: string; + targetAuthor?: string | null; + targetAuthorUuid?: string | null; + gatewayUrl?: string; + logDebug?: (message: string) => void; +}): Promise { + if (params.isRemove) { + return false; + } + const optionIndex = questionGatewayRuntime.resolveReactionIndex(params.reactionKey); + const key = buildKey(params.accountId, params.conversationKey, params.messageId); + if (optionIndex === undefined || !key) { + return false; + } + const target = targets.get(key); + if (!target) { + return false; + } + const authorKeys = resolveSignalApprovalTargetAuthorKeys(params); + if (!authorKeys.some((authorKey) => target.targetAuthorKeys.includes(authorKey))) { + return false; + } + if (target.expiresAtMs <= Date.now() || target.terminal) { + target.terminal = true; + params.logDebug?.(`signal: stale question reaction ignored id=${target.questionId}`); + return true; + } + const optionValue = target.optionValues[optionIndex]; + if (!optionValue) { + params.logDebug?.(`signal: out-of-range question reaction ignored id=${target.questionId}`); + return true; + } + try { + const result = await questionGatewayRuntime.resolveReaction({ + cfg: params.cfg, + questionId: target.questionId, + optionValue, + senderId: params.actorId, + gatewayUrl: params.gatewayUrl, + clientDisplayName: `Signal question (${params.actorId})`, + }); + target.terminal = result?.status === "answered" || result?.status === "already-terminal"; + if (result?.status === "already-terminal") { + params.logDebug?.(`signal: stale question reaction ignored id=${target.questionId}`); + } + } catch (error) { + params.logDebug?.(`signal: question reaction failed id=${target.questionId}: ${String(error)}`); + } + return true; +} diff --git a/extensions/signal/src/reaction-targets.ts b/extensions/signal/src/reaction-targets.ts new file mode 100644 index 000000000000..3e7e84a87e66 --- /dev/null +++ b/extensions/signal/src/reaction-targets.ts @@ -0,0 +1,17 @@ +import type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-result"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { registerSignalApprovalReactionTargetForDeliveredPayload } from "./approval-reactions.js"; +import { registerSignalQuestionReactionTargetForDeliveredPayload } from "./question-reactions.js"; + +export function registerSignalReactionTargetsForDeliveredPayload(params: { + cfg: OpenClawConfig; + target: { channel: string; to: string; accountId?: string | null }; + payload: ReplyPayload; + results: readonly OutboundDeliveryResult[]; + targetAuthor?: string | null; + targetAuthorUuid?: string | null; +}): void { + registerSignalQuestionReactionTargetForDeliveredPayload(params); + registerSignalApprovalReactionTargetForDeliveredPayload(params); +} diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index 9a7a17927607..da46e42a8bbe 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -63,7 +63,9 @@ vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({ })); vi.mock("openclaw/plugin-sdk/question-gateway-runtime", () => ({ - resolveQuestionOverGateway: (arg: unknown) => resolveQuestionOverGatewayMock(arg), + questionGatewayRuntime: { + resolveOption: (arg: unknown) => resolveQuestionOverGatewayMock(arg), + }, })); vi.mock("../../interactive-dispatch.js", () => ({ diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index c294bcdc977b..1cdfeb3e6c1e 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -11,6 +11,7 @@ import { resolveLegacyInteractiveTextFallback, } from "openclaw/plugin-sdk/interactive-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import { resolvePayloadMediaUrls, sendPayloadMediaSequenceAndFinalize, @@ -27,6 +28,7 @@ import { isSlackInteractiveRepliesEnabled, } from "./interactive-replies.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; +import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; import { SLACK_PRESENTATION_CAPABILITIES } from "./presentation.js"; import { parseSlackReplyBlockSegments, @@ -399,5 +401,59 @@ export const slackOutbound: ChannelOutboundAdapter = { }), ); }, + afterDeliverPayload: async ({ cfg, target, payload, results }) => { + const questionId = questionGatewayRuntime.readAskUserQuestionId(payload); + const slackData = payload.channelData?.slack as SlackOutboundChannelData | undefined; + if ( + !questionId || + slackData?.renderedPresentationProvenance !== SLACK_RENDERED_PRESENTATION_PROVENANCE + ) { + return; + } + const segments = parseSlackReplyBlockSegments(slackData.renderedPresentationSegments); + const placement = readSlackAuthoredTextPlacement(slackData.authoredTextPlacement); + if (!segments || !placement) { + return; + } + const deliveryMessages = resolveSlackReplyDeliveryMessages({ + authoredTextPlacement: placement, + segments, + text: payload.text, + }); + const blockMessageIndex = deliveryMessages.findIndex((message) => + message.blocks?.some((block) => block.type === "actions"), + ); + const deliveryMessage = deliveryMessages[blockMessageIndex]; + const result = + results[blockMessageIndex] ?? results.find((candidate) => candidate.channel === "slack"); + const deliveryBlocks = deliveryMessage?.blocks; + if (!deliveryMessage || !deliveryBlocks || !result?.messageId) { + return; + } + const channelId = result.channelId; + if (!channelId) { + return; + } + questionGatewayRuntime.registerChannelDelivery({ + questionId, + deliveryId: `slack:${target.accountId ?? "default"}:${channelId}:${result.messageId}`, + finalize: async (statusLine) => { + const { updateMessageSlack } = await loadSlackSendRuntime(); + const escapedStatusLine = escapeSlackMrkdwn(statusLine); + const blocks = [ + ...deliveryBlocks.filter((block) => block.type !== "actions"), + { type: "context", elements: [{ type: "mrkdwn", text: escapedStatusLine }] }, + ]; + await updateMessageSlack({ + cfg, + accountId: target.accountId ?? undefined, + channelId, + messageTs: result.messageId, + text: `${deliveryMessage.text}\n\n${escapedStatusLine}`, + blocks, + }); + }, + }); + }, ...createSlackAttachedSendAdapter(), }; diff --git a/extensions/slack/src/question-actions.ts b/extensions/slack/src/question-actions.ts index e726ea037135..db90b798e855 100644 --- a/extensions/slack/src/question-actions.ts +++ b/extensions/slack/src/question-actions.ts @@ -1,8 +1,5 @@ // Slack-private ask_user button envelope and resolution feedback. -import { - resolveQuestionOverGateway, - type ResolveQuestionOverGatewayParams, -} from "openclaw/plugin-sdk/question-gateway-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import { SLACK_BUTTON_VALUE_MAX } from "./presentation.js"; const SLACK_QUESTION_VALUE_PREFIX = "slq1:"; @@ -34,13 +31,14 @@ export function decodeSlackQuestionAction(value: unknown): SlackQuestionAction | return match?.[1] && match[2] ? { questionId: match[1], optionIndex: Number(match[2]) } : null; } +type ResolveQuestionParams = Parameters[0]; type QuestionResolver = ( - params: ResolveQuestionOverGatewayParams, -) => ReturnType; + params: ResolveQuestionParams, +) => ReturnType; export async function resolveSlackQuestionAction(params: { action: SlackQuestionAction; - cfg: ResolveQuestionOverGatewayParams["cfg"]; + cfg: ResolveQuestionParams["cfg"]; accountId: string; userId: string; respond: (text: string) => Promise; @@ -48,7 +46,7 @@ export async function resolveSlackQuestionAction(params: { }): Promise { let result: Awaited>; try { - result = await (params.resolveQuestion ?? resolveQuestionOverGateway)({ + result = await (params.resolveQuestion ?? questionGatewayRuntime.resolveOption)({ cfg: params.cfg, questionId: params.action.questionId, optionIndex: params.action.optionIndex, diff --git a/extensions/slack/src/question-finalization.test.ts b/extensions/slack/src/question-finalization.test.ts new file mode 100644 index 000000000000..65532b9dfc6d --- /dev/null +++ b/extensions/slack/src/question-finalization.test.ts @@ -0,0 +1,86 @@ +// Covers Slack question delivery capture and Block Kit final edit. +import { describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + update: vi.fn(), + registration: undefined as + | { finalize: (statusLine: string) => void | Promise; deliveryId: string } + | undefined, +})); +vi.mock("openclaw/plugin-sdk/question-gateway-runtime", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + questionGatewayRuntime: { + ...original.questionGatewayRuntime, + registerChannelDelivery: (registration: typeof hoisted.registration) => { + hoisted.registration = registration; + }, + }, + }; +}); +vi.mock("./send.js", () => ({ updateMessageSlack: hoisted.update })); + +import { slackOutbound } from "./outbound-adapter.js"; + +describe("Slack question finalization", () => { + it("removes action blocks and appends terminal context", async () => { + const questionId = "ask_0123456789abcdef0123456789abcdef"; + const payload = { + channelData: { askUser: { questionId } }, + presentation: { + blocks: [ + { type: "text" as const, text: "Pick one" }, + { + type: "buttons" as const, + buttons: ["One", "Two"].map((label) => ({ + label, + action: { type: "question" as const, questionId, optionValue: label }, + })), + }, + ], + }, + }; + const rendered = await slackOutbound.renderPresentation?.({ + payload, + presentation: payload.presentation, + ctx: { cfg: {}, to: "C123", text: "Pick one", payload }, + }); + expect(rendered).not.toBeNull(); + const slackData = rendered!.channelData?.slack as { + renderedPresentationSegments: unknown[]; + }; + slackData.renderedPresentationSegments.unshift({ + kind: "text", + text: "Preface", + mrkdwn: false, + }); + await slackOutbound.afterDeliverPayload?.({ + cfg: {}, + target: { channel: "slack", to: "C123", accountId: "default" }, + payload: rendered!, + results: [ + { channel: "slack", messageId: "44", channelId: "C123" }, + { channel: "slack", messageId: "55", channelId: "C123" }, + ], + }); + + await hoisted.registration?.finalize("Answered: "); + expect(hoisted.update).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "C123", + messageTs: "55", + text: expect.stringContaining("Answered: <!channel>"), + blocks: expect.arrayContaining([ + { + type: "context", + elements: [{ type: "mrkdwn", text: "Answered: <!channel>" }], + }, + ]), + }), + ); + const blocks = hoisted.update.mock.calls[0]?.[0]?.blocks as Array<{ type?: string }>; + expect(blocks.some((block) => block.type === "actions")).toBe(false); + }); +}); diff --git a/extensions/slack/src/send.runtime.ts b/extensions/slack/src/send.runtime.ts index 96bd3691e64d..8365104da82b 100644 --- a/extensions/slack/src/send.runtime.ts +++ b/extensions/slack/src/send.runtime.ts @@ -1,2 +1,7 @@ // Slack plugin module implements send behavior. -export { reconcileSlackUnknownSend, resolveSlackDmChannelId, sendMessageSlack } from "./send.js"; +export { + reconcileSlackUnknownSend, + resolveSlackDmChannelId, + sendMessageSlack, + updateMessageSlack, +} from "./send.js"; diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index e2dfb7c8cb40..2167b3077ea5 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -272,6 +272,30 @@ export type SlackSendResult = { threadTs?: string; }; +export async function updateMessageSlack(params: { + cfg: OpenClawConfig; + accountId?: string; + channelId: string; + messageTs: string; + text: string; + blocks: (Block | KnownBlock)[]; +}): Promise { + const cfg = requireRuntimeConfig(params.cfg, "Slack update"); + const account = resolveSlackAccount({ cfg, accountId: params.accountId }); + const token = resolveToken({ + accountId: account.accountId, + fallbackToken: account.botToken, + fallbackSource: account.botTokenSource, + }); + const client = getSlackWriteClient(token); + await client.chat.update({ + channel: params.channelId, + ts: params.messageTs, + text: truncateSlackText(params.text, SLACK_TEXT_LIMIT), + blocks: validateSlackBlocksArray(params.blocks), + }); +} + type SlackConversationMessage = { ts?: unknown; thread_ts?: unknown; diff --git a/extensions/telegram/src/bot-handlers.callback-questions.runtime.ts b/extensions/telegram/src/bot-handlers.callback-questions.runtime.ts index 10e066d930a7..806f0878cc11 100644 --- a/extensions/telegram/src/bot-handlers.callback-questions.runtime.ts +++ b/extensions/telegram/src/bot-handlers.callback-questions.runtime.ts @@ -1,24 +1,22 @@ // Telegram ask_user callback resolution and toast feedback. -import { - resolveQuestionOverGateway, - type ResolveQuestionOverGatewayParams, -} from "openclaw/plugin-sdk/question-gateway-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import type { TelegramQuestionCallback } from "./question-callback-data.js"; +type ResolveQuestionParams = Parameters[0]; type QuestionResolver = ( - params: ResolveQuestionOverGatewayParams, -) => ReturnType; + params: ResolveQuestionParams, +) => ReturnType; export async function handleTelegramQuestionCallback(params: { callback: TelegramQuestionCallback; - cfg: ResolveQuestionOverGatewayParams["cfg"]; + cfg: ResolveQuestionParams["cfg"]; senderId: string; feedback: (text: string, terminal: boolean) => Promise; resolveQuestion?: QuestionResolver; }): Promise { let result: Awaited>; try { - result = await (params.resolveQuestion ?? resolveQuestionOverGateway)({ + result = await (params.resolveQuestion ?? questionGatewayRuntime.resolveOption)({ cfg: params.cfg, questionId: params.callback.questionId, optionIndex: params.callback.optionIndex, diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 93163acb29d9..a6fd2add8d98 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -43,7 +43,9 @@ const questionGatewayHoisted = vi.hoisted(() => ({ })); vi.mock("openclaw/plugin-sdk/question-gateway-runtime", () => ({ - resolveQuestionOverGateway: questionGatewayHoisted.resolveQuestionOverGatewaySpy, + questionGatewayRuntime: { + resolveOption: questionGatewayHoisted.resolveQuestionOverGatewaySpy, + }, })); const { diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 08e9aa48e446..a7509e7d6891 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -10,6 +10,7 @@ import { attachChannelToResult, createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking"; import { resolveSendableOutboundReplyParts, @@ -316,6 +317,37 @@ export function createTelegramOutboundAdapter( }, renderPresentation: ({ payload, presentation }) => canonicalizeTelegramPresentationPayload({ ...payload, presentation }), + afterDeliverPayload: ({ cfg, target, payload, results }) => { + const questionId = questionGatewayRuntime.readAskUserQuestionId(payload); + const telegramResults = results.filter( + (candidate) => candidate.channel === "telegram" && candidate.messageId, + ); + const result = + telegramResults.find((candidate) => candidate.meta?.telegramHasInlineKeyboard === true) ?? + telegramResults.at(-1); + const text = ( + typeof result?.meta?.telegramDeliveredText === "string" + ? result.meta.telegramDeliveredText + : payload.text + )?.trim(); + if (!questionId || !result || !text) { + return; + } + const chatId = result.chatId ?? normalizeTelegramOutboundTarget(target.to); + questionGatewayRuntime.registerChannelDelivery({ + questionId, + deliveryId: `telegram:${target.accountId ?? "default"}:${chatId}:${result.messageId}`, + finalize: async (statusLine) => { + const { editMessageTelegram } = await loadSendModule(); + await editMessageTelegram(chatId, result.messageId, `${text}\n\n${statusLine}`, { + cfg, + accountId: target.accountId ?? undefined, + buttons: [], + verbose: false, + }); + }, + }); + }, pinDeliveredMessage: async ({ cfg, target, messageId, pin, gatewayClientScopes }) => { const { pinMessageTelegram } = await loadSendModule(); const outboundTo = normalizeTelegramOutboundTarget(target.to); diff --git a/extensions/telegram/src/question-finalization.test.ts b/extensions/telegram/src/question-finalization.test.ts new file mode 100644 index 000000000000..0df29d7b675a --- /dev/null +++ b/extensions/telegram/src/question-finalization.test.ts @@ -0,0 +1,63 @@ +// Covers Telegram question delivery capture and native final edit. +import { describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + edit: vi.fn(), + registration: undefined as + | { finalize: (statusLine: string) => void | Promise; deliveryId: string } + | undefined, +})); +vi.mock("openclaw/plugin-sdk/question-gateway-runtime", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + questionGatewayRuntime: { + ...original.questionGatewayRuntime, + registerChannelDelivery: (registration: typeof hoisted.registration) => { + hoisted.registration = registration; + }, + }, + }; +}); +vi.mock("./send.js", () => ({ editMessageTelegram: hoisted.edit })); + +import { createTelegramOutboundAdapter } from "./outbound-adapter.js"; + +describe("Telegram question finalization", () => { + it("removes buttons and appends terminal status", async () => { + const outbound = createTelegramOutboundAdapter(); + await outbound.afterDeliverPayload?.({ + cfg: {}, + target: { channel: "telegram", to: "123", accountId: "default" }, + payload: { + text: "Long preface\n\nPick one", + channelData: { + askUser: { questionId: "ask_0123456789abcdef0123456789abcdef" }, + }, + }, + results: [ + { + channel: "telegram", + messageId: "54", + chatId: "123", + meta: { telegramDeliveredText: "Long preface", telegramHasInlineKeyboard: false }, + }, + { + channel: "telegram", + messageId: "55", + chatId: "123", + meta: { telegramDeliveredText: "Pick one", telegramHasInlineKeyboard: true }, + }, + ], + }); + + await hoisted.registration?.finalize("Answered: One"); + expect(hoisted.edit).toHaveBeenCalledWith("123", "55", "Pick one\n\nAnswered: One", { + cfg: {}, + accountId: "default", + buttons: [], + verbose: false, + }); + }); +}); diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 4f4cec96e130..fbbe0aa30acf 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -168,6 +168,10 @@ type TelegramSendResult = { messageId: string; chatId: string; receipt?: MessageReceipt; + meta?: { + telegramDeliveredText?: string; + telegramHasInlineKeyboard?: boolean; + }; }; type TelegramLocationSendOpts = Pick< @@ -841,10 +845,15 @@ async function sendMessageTelegramWithContext( verbose: opts.verbose, gatewayClientScopes: opts.gatewayClientScopes, }); - const reportDelivery = async (messageId: string | number, deliveredChatId: string | number) => { + const reportDelivery = async ( + messageId: string | number, + deliveredChatId: string | number, + meta?: TelegramSendResult["meta"], + ) => { await opts.onDeliveryResult?.({ messageId: String(messageId), chatId: String(deliveredChatId), + ...(meta ? { meta } : {}), }); }; const recordDeliveredPromptContext = async ( @@ -1049,7 +1058,10 @@ async function sendMessageTelegramWithContext( ); const messageId = resolveTelegramMessageIdOrThrow(res, context); recordSentMessage(chatId, messageId, cfg); - await reportDelivery(messageId, res?.chat?.id ?? chatId); + await reportDelivery(messageId, res?.chat?.id ?? chatId, { + telegramDeliveredText: chunk.plainText, + telegramHasInlineKeyboard: index === chunks.length - 1 && Boolean(replyMarkup), + }); await recordDeliveredPromptContext( { message: res, @@ -1244,7 +1256,13 @@ async function sendMessageTelegramWithContext( ); const fallbackMessageId = resolveTelegramMessageIdOrThrow(plainResult.result, context); recordSentMessage(chatId, fallbackMessageId, cfg); - await reportDelivery(fallbackMessageId, plainResult.result?.chat?.id ?? chatId); + await reportDelivery(fallbackMessageId, plainResult.result?.chat?.id ?? chatId, { + telegramDeliveredText: fallbackText, + telegramHasInlineKeyboard: + index === chunks.length - 1 && + fallbackIndex === fallbackChunks.length - 1 && + Boolean(replyMarkup), + }); await recordDeliveredPromptContext( { message: plainResult.result, @@ -1267,7 +1285,10 @@ async function sendMessageTelegramWithContext( } const messageId = resolveTelegramMessageIdOrThrow(result, context); recordSentMessage(chatId, messageId, cfg); - await reportDelivery(messageId, result?.chat?.id ?? chatId); + await reportDelivery(messageId, result?.chat?.id ?? chatId, { + telegramDeliveredText: chunk.plainText, + telegramHasInlineKeyboard: index === chunks.length - 1 && Boolean(replyMarkup), + }); await recordDeliveredPromptContext( { message: result, @@ -1542,7 +1563,10 @@ async function sendMessageTelegramWithContext( const mediaMessageId = resolveTelegramMessageIdOrThrow(result, "media send"); const resolvedChatId = String(result?.chat?.id ?? chatId); recordSentMessage(chatId, mediaMessageId, cfg); - await reportDelivery(mediaMessageId, resolvedChatId); + await reportDelivery(mediaMessageId, resolvedChatId, { + ...(caption ? { telegramDeliveredText: caption } : {}), + telegramHasInlineKeyboard: !needsSeparateText && Boolean(replyMarkup), + }); await recordDeliveredPromptContext( { message: result, diff --git a/extensions/whatsapp/src/channel-outbound.ts b/extensions/whatsapp/src/channel-outbound.ts index 0d5224ecd704..782bb3ad5677 100644 --- a/extensions/whatsapp/src/channel-outbound.ts +++ b/extensions/whatsapp/src/channel-outbound.ts @@ -6,6 +6,7 @@ import { } from "openclaw/plugin-sdk/channel-outbound"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; import { chunkText } from "openclaw/plugin-sdk/reply-chunking"; import { createWhatsAppOutboundBase } from "./outbound-base.js"; import { normalizeWhatsAppPayloadTextPreservingIndentation } from "./outbound-media-contract.js"; @@ -16,6 +17,9 @@ import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; const loadWhatsAppApprovalReactionsModule = createLazyRuntimeModule( () => import("./approval-reactions.js"), ); +const loadWhatsAppQuestionReactionsModule = createLazyRuntimeModule( + () => import("./question-reactions.js"), +); function normalizeWhatsAppChannelPayloadText(text: string | undefined): string { return normalizeWhatsAppPayloadTextPreservingIndentation(text); @@ -29,6 +33,13 @@ function normalizeWhatsAppChannelSendText(text: string | undefined): string { async function prepareWhatsAppApprovalPayloadForDelivery( params: Parameters>[0], ) { + const questionPayload = questionGatewayRuntime.prepareReactionPayloadForDelivery({ + payload: params.payload, + presentation: params.presentation, + }); + if (questionPayload) { + return questionPayload; + } return (await loadWhatsAppApprovalReactionsModule()).prepareWhatsAppApprovalPayloadForDelivery({ payload: params.payload, presentation: params.presentation, @@ -38,6 +49,9 @@ async function prepareWhatsAppApprovalPayloadForDelivery( async function registerDeliveredWhatsAppApprovalPayload( params: Parameters>[0], ): Promise { + ( + await loadWhatsAppQuestionReactionsModule() + ).registerWhatsAppQuestionReactionTargetForDeliveredPayload(params); ( await loadWhatsAppApprovalReactionsModule() ).registerWhatsAppApprovalReactionTargetForDeliveredPayload(params); diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 58f1de01f473..8a76dd26f54b 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -28,6 +28,7 @@ import { readWebSelfIdentityForDecision, WhatsAppAuthUnstableError } from "../au import { getWhatsAppConnectionController } from "../connection-controller-runtime-context.js"; import { getPrimaryIdentityId, identitiesOverlap, resolveComparableIdentity } from "../identity.js"; import { addWhatsAppImagePreviewFields } from "../image-preview.js"; +import { maybeResolveWhatsAppQuestionReaction } from "../question-reactions.js"; import { cacheInboundMessageMeta } from "../quoted-message.js"; import { DEFAULT_RECONNECT_POLICY, computeBackoff, sleepWithAbort } from "../reconnect.js"; import type { OpenClawConfig } from "../runtime-api.js"; @@ -1480,6 +1481,18 @@ export async function attachWebInboxToSocket( if (!inbound) { return "completed"; } + if ( + await maybeResolveWhatsAppQuestionReaction({ + cfg: options.loadConfig?.() ?? options.cfg, + accountId: options.accountId, + msg, + senderId: inbound.senderE164 ?? inbound.from, + resolveReactionTargetJids, + logDebug: (message) => logWhatsAppVerbose(options.verbose, message), + }) + ) { + return "completed"; + } const readReceipt = buildReadReceiptTarget(inbound); const deliveryReadReceipt = inbound.access.isSelfChat ? undefined : readReceipt; if (context.skipStaleAppend === true) { diff --git a/extensions/whatsapp/src/question-reactions.test.ts b/extensions/whatsapp/src/question-reactions.test.ts new file mode 100644 index 000000000000..420fadbfdfa2 --- /dev/null +++ b/extensions/whatsapp/src/question-reactions.test.ts @@ -0,0 +1,119 @@ +// Covers WhatsApp delivery binding and numbered-reaction dispatch. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ resolve: vi.fn() })); +vi.mock("openclaw/plugin-sdk/question-gateway-runtime", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + questionGatewayRuntime: { + ...original.questionGatewayRuntime, + resolveReaction: hoisted.resolve, + }, + }; +}); + +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; +import { + clearWhatsAppQuestionReactionTargetsForTest, + maybeResolveWhatsAppQuestionReaction, + registerWhatsAppQuestionReactionTargetForDeliveredPayload, +} from "./question-reactions.js"; + +const questionId = "ask_0123456789abcdef0123456789abcdef"; + +function buildPayload() { + const presentation = { + blocks: [ + { type: "text" as const, text: "Pick one" }, + { + type: "buttons" as const, + buttons: ["One", "Two"].map((label) => ({ + label, + action: { type: "question" as const, questionId, optionValue: label }, + })), + }, + ], + }; + return questionGatewayRuntime.prepareReactionPayloadForDelivery({ + payload: { presentation, channelData: { askUser: { questionId } } }, + presentation, + }); +} + +describe("WhatsApp question reactions", () => { + beforeEach(() => { + clearWhatsAppQuestionReactionTargetsForTest(); + hoisted.resolve.mockReset().mockResolvedValue({ + status: "answered", + questionId: "choice", + optionValue: "Two", + }); + }); + + it("round-trips a delivered message and silently consumes a stale second tap", async () => { + const payload = buildPayload(); + expect(payload).not.toBeNull(); + expect( + registerWhatsAppQuestionReactionTargetForDeliveredPayload({ + cfg: {}, + target: { channel: "whatsapp", accountId: "default" }, + payload: payload!, + results: [{ channel: "whatsapp", messageId: "wa-1", toJid: "1555@s.whatsapp.net" }], + }), + ).toBe(true); + const msg = { + key: { remoteJid: "1555@s.whatsapp.net", participant: "1555@s.whatsapp.net" }, + message: { + reactionMessage: { + text: "2️⃣", + key: { id: "wa-1", remoteJid: "1555@s.whatsapp.net" }, + }, + }, + }; + const debug = vi.fn(); + + await expect( + maybeResolveWhatsAppQuestionReaction({ + cfg: {}, + accountId: "default", + msg: { + ...msg, + message: { + reactionMessage: { + ...msg.message.reactionMessage, + text: "4️⃣", + }, + }, + }, + senderId: "+1555", + logDebug: debug, + }), + ).resolves.toBe(true); + await expect( + maybeResolveWhatsAppQuestionReaction({ + cfg: {}, + accountId: "default", + msg, + senderId: "+1555", + logDebug: debug, + }), + ).resolves.toBe(true); + expect(hoisted.resolve).toHaveBeenCalledWith( + expect.objectContaining({ questionId, optionValue: "Two", senderId: "+1555" }), + ); + + await expect( + maybeResolveWhatsAppQuestionReaction({ + cfg: {}, + accountId: "default", + msg, + senderId: "+1555", + logDebug: debug, + }), + ).resolves.toBe(true); + expect(hoisted.resolve).toHaveBeenCalledOnce(); + expect(debug).toHaveBeenCalledWith(expect.stringContaining("stale question reaction ignored")); + }); +}); diff --git a/extensions/whatsapp/src/question-reactions.ts b/extensions/whatsapp/src/question-reactions.ts new file mode 100644 index 000000000000..cb32cf00d750 --- /dev/null +++ b/extensions/whatsapp/src/question-reactions.ts @@ -0,0 +1,193 @@ +// WhatsApp transport binding for numbered ask_user reactions. +import type { WAMessage } from "baileys"; +import type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-result"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveWhatsAppAccount } from "./accounts.js"; + +const TARGET_TTL_MS = 24 * 60 * 60 * 1_000; + +type WhatsAppQuestionReactionTarget = { + questionId: string; + optionValues: string[]; + terminal: boolean; + expiresAtMs: number; + cleanupTimer: ReturnType; +}; + +const targets = new Map(); + +function storeTarget(key: string, binding: { questionId: string; optionValues: string[] }): void { + const existing = targets.get(key); + if (existing) { + clearTimeout(existing.cleanupTimer); + } + const target: WhatsAppQuestionReactionTarget = { + ...binding, + terminal: false, + expiresAtMs: Date.now() + TARGET_TTL_MS, + cleanupTimer: setTimeout(() => { + if (targets.get(key) === target) { + targets.delete(key); + } + }, TARGET_TTL_MS), + }; + target.cleanupTimer.unref?.(); + targets.set(key, target); + questionGatewayRuntime.registerChannelDelivery({ + questionId: binding.questionId, + deliveryId: `whatsapp-reaction:${key}`, + finalize: () => { + target.terminal = true; + }, + }); +} + +function buildKey(accountId: string, remoteJid: string, messageId: string): string | undefined { + const parts = [accountId, remoteJid, messageId].map((part) => part.trim()); + return parts.every(Boolean) ? parts.join(":") : undefined; +} + +function addCandidate(values: string[], value: string | null | undefined): void { + const normalized = value?.trim(); + if (normalized && !values.includes(normalized)) { + values.push(normalized); + } +} + +function listDeliveredIdentities( + results: readonly OutboundDeliveryResult[], +): Array<{ messageId: string; remoteJid: string }> { + const identities: Array<{ messageId: string; remoteJid: string }> = []; + const seen = new Set(); + const add = (messageId?: string, remoteJid?: string) => { + const id = messageId?.trim() ?? ""; + const jid = remoteJid?.trim() ?? ""; + const key = `${jid}:${id}`; + if (id && id !== "unknown" && jid && !seen.has(key)) { + seen.add(key); + identities.push({ messageId: id, remoteJid: jid }); + } + }; + for (const result of results) { + if (result.channel !== "whatsapp") { + continue; + } + add(result.messageId, result.toJid); + for (const raw of result.receipt?.raw ?? []) { + add(raw.messageId, raw.toJid); + } + for (const part of result.receipt?.parts ?? []) { + add(part.raw?.messageId ?? part.platformMessageId, part.raw?.toJid); + } + } + return identities; +} + +export function registerWhatsAppQuestionReactionTargetForDeliveredPayload(params: { + cfg: OpenClawConfig; + target: { channel: string; accountId?: string | null }; + payload: ReplyPayload; + results: readonly OutboundDeliveryResult[]; +}): boolean { + const binding = questionGatewayRuntime.readReactionBinding(params.payload); + if (params.target.channel !== "whatsapp" || !binding) { + return false; + } + const accountId = resolveWhatsAppAccount({ + cfg: params.cfg, + accountId: params.target.accountId, + }).accountId; + let registered = false; + for (const identity of listDeliveredIdentities(params.results)) { + const key = buildKey(accountId, identity.remoteJid, identity.messageId); + if (!key) { + continue; + } + storeTarget(key, binding); + registered = true; + } + return registered; +} + +export async function maybeResolveWhatsAppQuestionReaction(params: { + cfg: OpenClawConfig; + accountId: string; + msg: WAMessage; + senderId: string; + gatewayUrl?: string; + resolveReactionTargetJids?: (jid: string) => Promise; + logDebug?: (message: string) => void; +}): Promise { + const reaction = params.msg.message?.reactionMessage; + const reactionKey = reaction?.text?.trim() ?? ""; + const messageId = reaction?.key?.id?.trim() ?? ""; + const optionIndex = questionGatewayRuntime.resolveReactionIndex(reactionKey); + if (optionIndex === undefined || !messageId) { + return false; + } + const remoteJids: string[] = []; + addCandidate(remoteJids, reaction?.key?.remoteJid); + addCandidate(remoteJids, params.msg.key?.remoteJid); + const candidates: string[] = []; + for (const remoteJid of remoteJids) { + addCandidate(candidates, remoteJid); + for (const mapped of (await params.resolveReactionTargetJids?.(remoteJid)) ?? []) { + addCandidate(candidates, mapped); + } + } + let matched: { key: string; target: WhatsAppQuestionReactionTarget } | undefined; + for (const remoteJid of candidates) { + const key = buildKey(params.accountId, remoteJid, messageId); + const target = key ? targets.get(key) : undefined; + if (key && target) { + matched = { key, target }; + break; + } + } + if (!matched) { + return false; + } + if (matched.target.expiresAtMs <= Date.now() || matched.target.terminal) { + matched.target.terminal = true; + params.logDebug?.(`whatsapp: stale question reaction ignored id=${matched.target.questionId}`); + return true; + } + const optionValue = matched.target.optionValues[optionIndex]; + if (!optionValue) { + params.logDebug?.( + `whatsapp: out-of-range question reaction ignored id=${matched.target.questionId}`, + ); + return true; + } + try { + const result = await questionGatewayRuntime.resolveReaction({ + cfg: params.cfg, + questionId: matched.target.questionId, + optionValue, + senderId: params.senderId, + gatewayUrl: params.gatewayUrl, + clientDisplayName: `WhatsApp question (${params.senderId})`, + }); + matched.target.terminal = + result?.status === "answered" || result?.status === "already-terminal"; + if (result?.status === "already-terminal") { + params.logDebug?.( + `whatsapp: stale question reaction ignored id=${matched.target.questionId}`, + ); + } + } catch (error) { + params.logDebug?.( + `whatsapp: question reaction failed id=${matched.target.questionId}: ${String(error)}`, + ); + } + return true; +} + +export function clearWhatsAppQuestionReactionTargetsForTest(): void { + for (const target of targets.values()) { + clearTimeout(target.cleanupTimer); + } + targets.clear(); +} diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 104bac5b652c..d0a003e649b1 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -265,7 +265,9 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +81: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter. // +3: question-gateway-runtime resolver plus request/result types. // +1: async memory prompt preparation registration. - 8153, + // +4: gateway-backed harness question runner, claim/cancel helpers, and caller type. + // Harvest: internal question runtime exports -2. + 8155, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -301,7 +303,9 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +32: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter. // +1: question-gateway-runtime resolver. // +1: async memory prompt preparation registration. - 4535, + // +3: gateway-backed harness question runner and claim/cancel helpers. + // Harvest: internal question runtime callable -1. + 4537, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index eab88cd2bf61..65d573b65f1a 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -18,8 +18,6 @@ "voicewake.routing.changed": "iOS only consumes voicewake.changed trigger updates; routing changes are not surfaced.", "plugin.approval.requested": "Plugin approval prompts are not implemented on iOS.", "plugin.approval.resolved": "Plugin approval prompts are not implemented on iOS.", - "question.requested": "Interactive question UI ships on web/channels first; mobile apps ignore operator question events until their card UI lands.", - "question.resolved": "Interactive question UI ships on web/channels first; mobile apps ignore operator question events until their card UI lands.", "openclaw.approval.requested": "OpenClaw system-agent config approvals are a web/desktop operator surface; iOS has no operator-approval prompt.", "openclaw.approval.resolved": "OpenClaw system-agent config approvals are a web/desktop operator surface; iOS has no operator-approval prompt.", "terminal.data": "Embedded terminal is a web/desktop surface; iOS has no terminal client.", @@ -46,8 +44,6 @@ "voicewake.routing.changed": "Android reads voicewake state on demand via voicewake.get; no push consumer yet.", "plugin.approval.requested": "Plugin approval prompts are not implemented on Android.", "plugin.approval.resolved": "Plugin approval prompts are not implemented on Android.", - "question.requested": "Interactive question UI ships on web/channels first; mobile apps ignore operator question events until their card UI lands.", - "question.resolved": "Interactive question UI ships on web/channels first; mobile apps ignore operator question events until their card UI lands.", "openclaw.approval.requested": "OpenClaw system-agent config approvals are a web/desktop operator surface; Android has no operator-approval prompt.", "openclaw.approval.resolved": "OpenClaw system-agent config approvals are a web/desktop operator surface; Android has no operator-approval prompt.", "terminal.data": "Embedded terminal is a web/desktop surface; Android has no terminal client.", diff --git a/scripts/protocol-gen-kotlin.ts b/scripts/protocol-gen-kotlin.ts index d4858f47edda..769df3eee3e8 100644 --- a/scripts/protocol-gen-kotlin.ts +++ b/scripts/protocol-gen-kotlin.ts @@ -48,6 +48,11 @@ const schemaNames = new Map([ ["NodeEventParams", "GatewayNodeEventParams"], ["NodeInvokeResultParams", "GatewayNodeInvokeResultParams"], ["NodeInvokeRequestEvent", "GatewayNodeInvokeRequest"], + ["QuestionOption", "QuestionOption"], + ["Question", "Question"], + ["QuestionAnswers", "QuestionAnswers"], + ["QuestionRecord", "QuestionRecord"], + ["QuestionListResult", "QuestionListResult"], ]); const androidEnums: EnumSpec[] = [ @@ -226,6 +231,10 @@ function emitWireModels(): string[] { if (selected) { return selected; } + const stringUnion = schema.anyOf?.map(literalValue); + if (stringUnion?.length && stringUnion.every((value) => typeof value === "string")) { + return "String"; + } if (schema.type === "string" || typeof schema.const === "string") { return "String"; } diff --git a/src/agents/embedded-agent-payloads.ts b/src/agents/embedded-agent-payloads.ts index c6ed112c8823..8947bce43d03 100644 --- a/src/agents/embedded-agent-payloads.ts +++ b/src/agents/embedded-agent-payloads.ts @@ -18,4 +18,8 @@ export type BlockReplyPayload = { replyToCurrent?: boolean; /** Portable controls attached to a harness-owned blocking prompt. */ presentation?: MessagePresentation; + /** Runtime-authored text is the fallback for the portable presentation. */ + presentationTextMode?: "fallback"; + /** Channel-specific routing metadata for runtime-owned interactions. */ + channelData?: Record; }; diff --git a/src/agents/embedded-agent-runner/run/attempt.queue-message.ts b/src/agents/embedded-agent-runner/run/attempt.queue-message.ts index 3d2a60870633..537b0a44f546 100644 --- a/src/agents/embedded-agent-runner/run/attempt.queue-message.ts +++ b/src/agents/embedded-agent-runner/run/attempt.queue-message.ts @@ -5,9 +5,9 @@ import { toErrorObject } from "../../../infra/errors.js"; import type { ImageContent } from "../../../llm/types.js"; import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.types.js"; import { - cancelPendingAskUserForSession, - claimPendingAskUserAnswer, -} from "../../tools/ask-user-tool.js"; + cancelPendingAgentQuestionForSession, + claimPendingAgentQuestionAnswer, +} from "../../harness/gateway-question.js"; import { log } from "../logger.js"; import type { EmbeddedAgentQueueMessageOptions } from "../run-state.js"; @@ -242,7 +242,7 @@ export async function steerActiveSessionWithOptionalDeliveryWait( const isPlainTextAnswer = !options?.images?.length; if (isInboundUserMessage && !isPlainTextAnswer) { try { - await cancelPendingAskUserForSession({ sessionKey, resolvedBy: "image-reply" }); + await cancelPendingAgentQuestionForSession({ sessionKey, resolvedBy: "image-reply" }); } catch (error) { log.warn(`failed to cancel ask_user before image steering: ${String(error)}`); } @@ -250,7 +250,7 @@ export async function steerActiveSessionWithOptionalDeliveryWait( if ( isInboundUserMessage && isPlainTextAnswer && - (await claimPendingAskUserAnswer({ + (await claimPendingAgentQuestionAnswer({ sessionKey, text, persist: options.userTurnTranscriptRecorder diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts index a69c7e9364b8..89fa7b32a843 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts @@ -346,11 +346,10 @@ describe("handleToolExecutionStart read path checks", () => { presentationTextMode: "fallback", presentation: { blocks: [ + { type: "text", text: "Where should this deploy?" }, { type: "text", text: [ - "Where should this deploy?", - "", "- Staging (Recommended): Safer default", "- Production", "", @@ -430,7 +429,11 @@ describe("handleToolExecutionStart read path checks", () => { await vi.waitFor(() => expect(onToolResult).toHaveBeenCalledOnce()); const payload = onToolResult.mock.calls[0]?.[0]; - expect(payload?.text).toContain("Reply with the number, the option text, or your own answer."); + expect(payload?.text).toContain( + questions.length > 1 + ? "Reply by number or question id. Use a declared option where choices are fixed." + : "Reply with the number, the option text, or your own answer.", + ); expect(payload).not.toHaveProperty("presentation"); expect(payload).not.toHaveProperty("presentationTextMode"); await activation.finish(); diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.ts b/src/agents/embedded-agent-subscribe.handlers.tools.ts index 55424688b539..a6d7027ba4e7 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.ts @@ -12,7 +12,6 @@ import { readStringValue, } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import type { QuestionRequestQuestion } from "../../packages/gateway-protocol/src/schema/questions.js"; import { HEARTBEAT_RESPONSE_TOOL_NAME, normalizeHeartbeatToolResponse, @@ -38,7 +37,7 @@ import { parseInteractiveParam, parseJsonMessageParam, } from "../infra/outbound/message-action-params.js"; -import { hasReplyPayloadContent, type MessagePresentation } from "../interactive/payload.js"; +import { hasReplyPayloadContent } from "../interactive/payload.js"; import type { PluginHookAfterToolCallEvent } from "../plugins/types.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { hasTopLevelShellControlOperator, splitShellArgs } from "../utils/shell-argv.js"; @@ -90,7 +89,7 @@ import { } from "./embedded-agent-subscribe.tools.js"; import { inferToolMetaFromArgs } from "./embedded-agent-utils.js"; import { parseExecApprovalResultText } from "./exec-approval-result.js"; -import { formatAgentHarnessUserInputPrompt } from "./harness/user-input-bridge.js"; +import { buildAgentHarnessQuestionPromptPayload } from "./harness/user-input-bridge.js"; import type { AgentEvent } from "./runtime/index.js"; import { createToolValidationErrorSummary, @@ -121,44 +120,6 @@ const hookRunnerGlobalModuleLoader = createLazyImportLoader import("../plugins/hook-runner-global.js"), ); -function buildAskUserQuestionPresentation(params: { - questionId: string; - questions: QuestionRequestQuestion[]; -}): MessagePresentation | undefined { - // Button taps resolve atomically, so v1 keeps multi-question records text-only. - if (params.questions.length !== 1) { - return undefined; - } - const [question] = params.questions; - if (!question || question.multiSelect || question.isSecret || question.options.length === 0) { - return undefined; - } - const presentationText = [ - question.question, - "", - ...question.options.map( - (option) => `- ${option.label}${option.description ? `: ${option.description}` : ""}`, - ), - "", - "Tap an option, or reply with the option text or your own answer.", - ].join("\n"); - return { - blocks: [ - { type: "text", text: presentationText }, - { - type: "buttons", - buttons: question.options.map((option) => ({ - label: option.label, - action: { - type: "question", - questionId: params.questionId, - optionValue: option.label, - }, - })), - }, - ], - }; -} const fallbackToolTerminalObservers = new WeakMap< ToolHandlerContext["state"], ReturnType @@ -1260,15 +1221,13 @@ export function handleToolExecutionStart( if (!questions) { return; } - const prompt = formatAgentHarnessUserInputPrompt(questions, { - intro: "Question for you:", - }); - const presentation = buildAskUserQuestionPresentation({ questionId, questions }); - return ctx.params.onToolResult?.({ - text: `${prompt}\n\nReply with the number, the option text, or your own answer.`, - ...(presentation ? { presentation, presentationTextMode: "fallback" as const } : {}), - channelData: { askUser: { questionId } }, - }); + return ctx.params.onToolResult?.( + buildAgentHarnessQuestionPromptPayload({ + questionId, + questions, + options: { intro: "Question for you:" }, + }), + ); }) .then( () => settleAskUserPromptDelivery(questionId), diff --git a/src/agents/harness/gateway-question.test.ts b/src/agents/harness/gateway-question.test.ts new file mode 100644 index 000000000000..07d33f2e5f86 --- /dev/null +++ b/src/agents/harness/gateway-question.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, it, vi } from "vitest"; +import { + cancelPendingAgentQuestionForSession, + claimPendingAgentQuestionAnswer, + registerPendingAgentQuestion, + runAgentHarnessGatewayQuestion, + type AgentHarnessQuestionGatewayCall, +} from "./gateway-question.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const questions = [ + { + id: "answer", + header: "Answer", + question: "What should happen?", + isOther: true, + options: [], + }, +] as const; + +describe("gateway harness questions", () => { + it("does not request a gateway question when the session reservation conflicts", async () => { + const gatewayCall = vi.fn(); + const reservation = registerPendingAgentQuestion({ + questionId: "ask_00000000000000000000000000000000", + sessionKey: "agent:main:conflict", + questions, + gatewayCall, + }); + + await expect( + runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:conflict", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply: vi.fn() }, + }), + ).rejects.toThrow("session already has a pending gateway question"); + expect(gatewayCall).not.toHaveBeenCalled(); + reservation.dispose(); + }); + + it("fails a pending claim closed when disposed before registration attaches", async () => { + const claim = registerPendingAgentQuestion({ + questionId: "ask_00000000000000000000000000000000", + sessionKey: "agent:main:dispose-before-attach", + questions, + gatewayCall: vi.fn(), + }); + const answer = claimPendingAgentQuestionAnswer({ + sessionKey: "agent:main:dispose-before-attach", + text: "Continue", + }); + + claim.dispose(); + + await expect(answer).resolves.toBe(false); + }); + + it("reserves the session and suppresses a prompt cancelled during registration", async () => { + const registration = deferred<{ id: string }>(); + const calls: Array<{ method: string; params: unknown }> = []; + let resolveCount = 0; + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { + calls.push({ method, params }); + if (method === "question.request") { + return await registration.promise; + } + if (method === "question.resolve") { + resolveCount += 1; + if (resolveCount === 1) { + throw Object.assign(new Error("not registered yet"), { + name: "GatewayClientRequestError", + details: { reason: "QUESTION_NOT_FOUND" }, + }); + } + return { status: "cancelled" }; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + const onBlockReply = vi.fn(); + const run = runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:registering", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply }, + questionId: "ask_1234567890abcdef1234567890abcdef", + }); + + await expect( + cancelPendingAgentQuestionForSession({ + sessionKey: "agent:main:registering", + resolvedBy: "image-reply", + }), + ).resolves.toBe(true); + registration.resolve({ id: "ask_1234567890abcdef1234567890abcdef" }); + + await expect(run).resolves.toEqual({ status: "cancelled" }); + expect(onBlockReply).not.toHaveBeenCalled(); + expect(calls.filter((entry) => entry.method === "question.resolve")).toHaveLength(2); + expect(calls.some((entry) => entry.method === "question.waitAnswer")).toBe(false); + }); + + it("observes an early wait rejection without delivering a stale prompt", async () => { + const waitError = new Error("gateway disconnected"); + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { + if (method === "question.request") { + return { id: (params as { id: string }).id }; + } + if (method === "question.waitAnswer") { + throw waitError; + } + if (method === "question.resolve") { + return { status: "cancelled" }; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + const onBlockReply = vi.fn(); + const run = runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:delivery", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply }, + questionId: "ask_abcdef1234567890abcdef1234567890", + }); + await expect(run).rejects.toBe(waitError); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + + it("returns an answer that wins a registration cancellation race", async () => { + const registration = deferred<{ id: string }>(); + const answers = { answers: { answer: { answers: ["Continue"] } } }; + let resolveCount = 0; + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method) => { + if (method === "question.request") { + return await registration.promise; + } + if (method === "question.resolve") { + resolveCount += 1; + throw Object.assign(new Error(resolveCount === 1 ? "not registered" : "already answered"), { + name: "GatewayClientRequestError", + details: { + reason: resolveCount === 1 ? "QUESTION_NOT_FOUND" : "QUESTION_ALREADY_TERMINAL", + }, + }); + } + if (method === "question.waitAnswer") { + return { status: "answered", answers }; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + const onBlockReply = vi.fn(); + const run = runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:registration-answer", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply }, + questionId: "ask_11111111111111111111111111111111", + }); + + await expect( + cancelPendingAgentQuestionForSession({ + sessionKey: "agent:main:registration-answer", + resolvedBy: "image-reply", + }), + ).resolves.toBe(true); + registration.resolve({ id: "ask_11111111111111111111111111111111" }); + + await expect(run).resolves.toEqual({ status: "answered", answers }); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + + it("returns an answer recovered after the request response is lost", async () => { + const answers = { answers: { answer: { answers: ["Continue"] } } }; + const requestError = new Error("request response lost"); + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method) => { + if (method === "question.request") { + throw requestError; + } + if (method === "question.resolve") { + throw Object.assign(new Error("already answered"), { + name: "GatewayClientRequestError", + details: { reason: "QUESTION_ALREADY_TERMINAL" }, + }); + } + if (method === "question.waitAnswer") { + return { status: "answered", answers }; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + + await expect( + runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:lost-request-response", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply: vi.fn() }, + questionId: "ask_22222222222222222222222222222222", + }), + ).resolves.toEqual({ status: "answered", answers }); + }); + + it("buffers a plain-text reply while gateway registration is pending", async () => { + const registration = deferred<{ id: string }>(); + const answer = deferred<{ + status: "answered"; + answers: { answers: Record }; + }>(); + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { + if (method === "question.request") { + return await registration.promise; + } + if (method === "question.waitAnswer") { + return await answer.promise; + } + if (method === "question.resolve") { + const resolvedAnswers = ( + params as { answers?: { answers: Record } } + ).answers; + if (!resolvedAnswers) { + return { status: "cancelled" }; + } + const result = { status: "answered" as const, answers: resolvedAnswers }; + answer.resolve(result); + return result; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + const onBlockReply = vi.fn(); + const run = runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:buffered-reply", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply }, + questionId: "ask_33333333333333333333333333333333", + }); + + // The claim settles only after registration commits so a failed request + // cannot swallow the reply. + const claim = claimPendingAgentQuestionAnswer({ + sessionKey: "agent:main:buffered-reply", + text: "Continue", + }); + registration.resolve({ id: "ask_33333333333333333333333333333333" }); + await expect(claim).resolves.toBe(true); + + await expect(run).resolves.toEqual({ + status: "answered", + answers: { answers: { answer: { answers: ["Continue"] } } }, + }); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + + it("releases a claimed reply when gateway registration fails", async () => { + const registration = deferred<{ id: string }>(); + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method) => { + if (method === "question.request") { + return await registration.promise; + } + if (method === "question.resolve") { + return { status: "cancelled" }; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + const run = runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:failed-registration", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply: vi.fn() }, + questionId: "ask_44444444444444444444444444444444", + }); + + const claim = claimPendingAgentQuestionAnswer({ + sessionKey: "agent:main:failed-registration", + text: "Continue", + }); + registration.reject(new Error("gateway unavailable")); + await expect(claim).resolves.toBe(false); + await expect(run).rejects.toThrow("gateway unavailable"); + }); + + it("accepts a later text answer after cancellation fails", async () => { + const answer = deferred<{ + status: "answered"; + answers: { answers: Record }; + }>(); + let cancelAttempts = 0; + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { + if (method === "question.request") { + return { id: (params as { id: string }).id }; + } + if (method === "question.waitAnswer") { + return await answer.promise; + } + if (method === "question.resolve") { + const resolvedAnswers = ( + params as { answers?: { answers: Record } } + ).answers; + if (!resolvedAnswers) { + cancelAttempts += 1; + throw new Error("temporary gateway failure"); + } + const result = { status: "answered" as const, answers: resolvedAnswers }; + answer.resolve(result); + return result; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + const onBlockReply = vi.fn(); + const run = runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:cancel-retry", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply }, + questionId: "ask_44444444444444444444444444444444", + }); + await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledOnce()); + + await expect( + cancelPendingAgentQuestionForSession({ + sessionKey: "agent:main:cancel-retry", + resolvedBy: "image-reply", + }), + ).rejects.toThrow("temporary gateway failure"); + await expect( + claimPendingAgentQuestionAnswer({ + sessionKey: "agent:main:cancel-retry", + text: "Continue", + }), + ).resolves.toBe(true); + + await expect(run).resolves.toEqual({ + status: "answered", + answers: { answers: { answer: { answers: ["Continue"] } } }, + }); + expect(cancelAttempts).toBe(1); + }); + + it("returns a gateway answer without waiting for stalled prompt delivery", async () => { + const answer = deferred<{ + status: "answered"; + answers: { answers: Record }; + }>(); + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { + if (method === "question.request") { + return { id: (params as { id: string }).id }; + } + if (method === "question.waitAnswer") { + return await answer.promise; + } + if (method === "question.resolve") { + const answers = (params as { answers?: { answers: Record } }) + .answers; + if (answers) { + answer.resolve({ status: "answered", answers }); + return { status: "answered", answers }; + } + return { status: "cancelled" }; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + let deliverySignal: AbortSignal | undefined; + const onBlockReply = vi.fn( + async (_payload, context?: { abortSignal?: AbortSignal }) => + await new Promise(() => { + deliverySignal = context?.abortSignal; + }), + ); + const run = runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:stalled-delivery", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply }, + questionId: "ask_0123456789abcdef0123456789abcdef", + }); + await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledOnce()); + await expect( + claimPendingAgentQuestionAnswer({ + sessionKey: "agent:main:stalled-delivery", + text: "Continue", + }), + ).resolves.toBe(true); + const answers = { answers: { answer: { answers: ["Continue"] } } }; + + await expect(run).resolves.toEqual({ status: "answered", answers }); + expect(deliverySignal?.aborted).toBe(true); + }); + + it("does not deliver a prompt for an already-terminal gateway question", async () => { + const answers = { answers: { answer: { answers: ["Continue"] } } }; + const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { + if (method === "question.request") { + return { id: (params as { id: string }).id }; + } + if (method === "question.waitAnswer") { + return { status: "answered", answers }; + } + throw new Error(`unexpected gateway method: ${method}`); + }; + const onBlockReply = vi.fn(); + + await expect( + runAgentHarnessGatewayQuestion({ + questions, + sessionKey: "agent:main:already-terminal", + timeoutMs: 60_000, + gatewayCall, + delivery: { onBlockReply }, + questionId: "ask_55555555555555555555555555555555", + }), + ).resolves.toEqual({ status: "answered", answers }); + expect(onBlockReply).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/harness/gateway-question.ts b/src/agents/harness/gateway-question.ts new file mode 100644 index 000000000000..121a9e96791a --- /dev/null +++ b/src/agents/harness/gateway-question.ts @@ -0,0 +1,455 @@ +import { randomBytes } from "node:crypto"; +import type { + QuestionRequestQuestion, + QuestionWaitAnswerResult, +} from "../../../packages/gateway-protocol/src/schema/questions.js"; +import type { EmbeddedRunAttemptParams } from "../embedded-agent-runner/run/types.js"; +import { + buildAgentHarnessUserInputAnswers, + type AgentHarnessUserInputAnswers, + deliverAgentHarnessQuestionPrompt, + type AgentHarnessUserInputPromptOptions, + type AgentHarnessUserInputQuestion, +} from "./user-input-bridge.js"; + +const QUESTION_RPC_GRACE_MS = 10_000; +const TERMINAL_QUESTION_ERROR_REASONS = new Set([ + "QUESTION_ALREADY_TERMINAL", + "QUESTION_NOT_FOUND", +]); + +export type AgentHarnessQuestionGatewayCall = ( + method: string, + opts: { timeoutMs?: number }, + params?: unknown, + extra?: { signal?: AbortSignal }, +) => Promise; + +type PendingAgentQuestion = { + questionId: string; + sessionKey: string; + questions: readonly AgentHarnessUserInputQuestion[]; + gatewayCall: AgentHarnessQuestionGatewayCall; + registration: Promise; + attachRegistration: (promise: Promise) => void; + answer?: Promise; + bufferedAnswers?: AgentHarnessUserInputAnswers; + cancelRequested: boolean; + onCancel?: (resolvedBy: string) => void; + resolving: boolean; +}; + +const pendingAgentQuestions = new Map(); + +function readQuestionErrorReason(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const requestError = error as { details?: unknown; name?: unknown }; + if (requestError.name !== "GatewayClientRequestError") { + return undefined; + } + const details = requestError.details; + if (!details || typeof details !== "object" || Array.isArray(details)) { + return undefined; + } + const reason = (details as { reason?: unknown }).reason; + return typeof reason === "string" ? reason : undefined; +} + +function isTerminalAgentQuestionError(error: unknown): boolean { + const reason = readQuestionErrorReason(error); + return reason !== undefined && TERMINAL_QUESTION_ERROR_REASONS.has(reason); +} + +async function observeCommittedAnswer( + answer: Promise | undefined, +): Promise { + if (!answer) { + return false; + } + let timer: ReturnType | undefined; + try { + const result = await Promise.race([ + answer, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), 1_000); + timer.unref?.(); + }), + ]); + return result?.status === "answered"; + } catch { + return false; + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +async function resolvePendingAgentQuestionAnswers( + state: PendingAgentQuestion, + answers: AgentHarnessUserInputAnswers, +): Promise { + try { + await state.gatewayCall( + "question.resolve", + {}, + { id: state.questionId, answers, resolvedBy: "plain-text" }, + ); + return true; + } catch (error) { + if (isTerminalAgentQuestionError(error)) { + return false; + } + // The wait observes a committed resolve even if the resolve response was lost. + if (await observeCommittedAnswer(state.answer)) { + return true; + } + state.resolving = false; + throw error; + } +} + +/** Registers one gateway question as the next plain-text claim target for its session. */ +export function registerPendingAgentQuestion(params: { + questionId: string; + sessionKey: string; + questions: readonly AgentHarnessUserInputQuestion[]; + gatewayCall: AgentHarnessQuestionGatewayCall; + answer?: Promise; + onCancel?: (resolvedBy: string) => void; +}): { + attachRegistration: (promise: Promise) => void; + setAnswer: (answer: Promise) => Promise; + isCancellationRequested: () => boolean; + isResolving: () => boolean; + dispose: () => void; +} { + const sessionKey = params.sessionKey.trim(); + const existing = pendingAgentQuestions.get(sessionKey); + if (existing) { + throw new Error(`session already has a pending gateway question: ${existing.questionId}`); + } + let resolveRegistration!: (value: unknown) => void; + let rejectRegistration!: (error: unknown) => void; + const registration = new Promise((resolve, reject) => { + resolveRegistration = resolve; + rejectRegistration = reject; + }); + void registration.catch(() => undefined); + let registrationAttached = false; + const state: PendingAgentQuestion = { + ...params, + sessionKey, + registration, + attachRegistration: (promise) => { + if (registrationAttached) { + throw new Error("gateway question registration already attached"); + } + registrationAttached = true; + promise.then(resolveRegistration, rejectRegistration); + }, + cancelRequested: false, + resolving: false, + }; + pendingAgentQuestions.set(sessionKey, state); + return { + attachRegistration: state.attachRegistration, + setAnswer: async (answer) => { + if (pendingAgentQuestions.get(sessionKey) !== state) { + return false; + } + state.answer = answer; + if (!state.bufferedAnswers) { + return false; + } + const resolved = await resolvePendingAgentQuestionAnswers(state, state.bufferedAnswers); + if (resolved) { + delete state.bufferedAnswers; + } + return resolved; + }, + isCancellationRequested: () => state.cancelRequested, + isResolving: () => state.cancelRequested || state.resolving, + dispose: () => { + if (pendingAgentQuestions.get(sessionKey) === state) { + pendingAgentQuestions.delete(sessionKey); + } + if (!registrationAttached) { + rejectRegistration(new Error("gateway question registration disposed before attachment")); + } + }, + }; +} + +/** Claims the next queued plain-text message for the session's gateway question. */ +export async function claimPendingAgentQuestionAnswer(params: { + sessionKey?: string; + text: string; + persist?: () => Promise; +}): Promise { + const sessionKey = params.sessionKey?.trim(); + const state = sessionKey ? pendingAgentQuestions.get(sessionKey) : undefined; + if (!state || state.cancelRequested || state.resolving) { + return false; + } + state.resolving = true; + const answers = buildAgentHarnessUserInputAnswers(state.questions, params.text); + if (!state.answer) { + // A consumed claim must be backed by a registered gateway question; if + // registration fails the message returns false so normal steering runs. + try { + await state.registration; + } catch { + state.resolving = false; + return false; + } + if (pendingAgentQuestions.get(state.sessionKey) !== state) { + state.resolving = false; + return false; + } + } + // Persist only once this claim is committed to consuming the message; + // persisting earlier double-records the turn when the claim falls through. + try { + await params.persist?.(); + } catch (error) { + state.resolving = false; + throw error; + } + if (!state.answer) { + // Accepted tradeoff: the claim acknowledges once registration commits. If + // the later buffered resolve fails non-terminally the question is still + // live on every surface, so the user can re-answer; replaying the text + // into steering would double-process it in the common path. + state.bufferedAnswers = answers; + return true; + } + return await resolvePendingAgentQuestionAnswers(state, answers); +} + +/** Cancels a question before the same inbound message takes another route. */ +export async function cancelPendingAgentQuestionForSession(params: { + sessionKey?: string; + resolvedBy: string; +}): Promise { + const sessionKey = params.sessionKey?.trim(); + const state = sessionKey ? pendingAgentQuestions.get(sessionKey) : undefined; + if (!state || state.resolving) { + return false; + } + state.cancelRequested = true; + state.resolving = true; + try { + await state.gatewayCall( + "question.resolve", + { timeoutMs: QUESTION_RPC_GRACE_MS }, + { id: state.questionId, cancel: true, resolvedBy: params.resolvedBy }, + ); + state.onCancel?.(params.resolvedBy); + return true; + } catch (error) { + if (isTerminalAgentQuestionError(error)) { + state.onCancel?.(params.resolvedBy); + return true; + } + state.cancelRequested = false; + state.resolving = false; + throw error; + } +} + +type RunAgentHarnessGatewayQuestionParams = { + questions: readonly AgentHarnessUserInputQuestion[]; + sessionKey: string; + agentId?: string; + timeoutMs: number; + gatewayCall: AgentHarnessQuestionGatewayCall; + delivery: Pick; + promptOptions?: AgentHarnessUserInputPromptOptions; + signal?: AbortSignal; + questionId?: string; +}; + +/** Registers, presents, and waits for one harness-owned gateway question record. */ +export async function runAgentHarnessGatewayQuestion( + params: RunAgentHarnessGatewayQuestionParams, +): Promise { + const questionId = params.questionId ?? `ask_${randomBytes(16).toString("hex")}`; + const questions: QuestionRequestQuestion[] = params.questions.map((question) => ({ + ...question, + options: [...(question.options ?? [])], + })); + let aborted = false; + params.signal?.throwIfAborted(); + const claim = registerPendingAgentQuestion({ + questionId, + sessionKey: params.sessionKey, + questions: params.questions, + gatewayCall: params.gatewayCall, + }); + const registration = Promise.resolve().then( + () => + params.gatewayCall( + "question.request", + {}, + { + id: questionId, + questions, + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + timeoutMs: params.timeoutMs, + }, + params.signal ? { signal: params.signal } : undefined, + ) as Promise<{ id?: unknown }>, + ); + claim.attachRegistration(registration); + const cancel = async (resolvedBy: string): Promise => { + try { + return (await params.gatewayCall( + "question.resolve", + { timeoutMs: QUESTION_RPC_GRACE_MS }, + { id: questionId, cancel: true, resolvedBy }, + )) as QuestionWaitAnswerResult; + } catch (error) { + if (!isTerminalAgentQuestionError(error)) { + throw error; + } + try { + const result = (await params.gatewayCall( + "question.waitAnswer", + { timeoutMs: QUESTION_RPC_GRACE_MS }, + { id: questionId, timeoutMs: 1_000 }, + )) as QuestionWaitAnswerResult; + return result.status === "answered" ? result : undefined; + } catch { + return undefined; + } + } + }; + const onAbort = () => { + aborted = true; + // Release the session slot synchronously so a replacement request can register + // while the best-effort gateway cancellation finishes. + claim.dispose(); + void cancel("run-abort").catch(() => undefined); + }; + + try { + params.signal?.addEventListener("abort", onAbort, { once: true }); + if (params.signal?.aborted) { + onAbort(); + params.signal.throwIfAborted(); + } + const request = await registration; + if (request.id !== questionId) { + throw new Error("question.request returned an unexpected question id"); + } + // Cancellation can race registration. Retry against the committed ID and + // never present a stale prompt after the inbound message took another route. + if (aborted || claim.isCancellationRequested() || params.signal?.aborted) { + const terminal = await cancel( + aborted || params.signal?.aborted ? "run-abort" : "superseded-input", + ); + if (terminal?.status === "answered") { + return terminal; + } + return { status: "cancelled" }; + } + const answer = params.gatewayCall( + "question.waitAnswer", + { timeoutMs: params.timeoutMs + QUESTION_RPC_GRACE_MS }, + { id: questionId, timeoutMs: params.timeoutMs }, + params.signal ? { signal: params.signal } : undefined, + ) as Promise; + const bufferedAnswer = await claim.setAnswer(answer); + const answerOutcome = answer.then( + (result) => ({ kind: "answer" as const, result }), + (error: unknown) => ({ kind: "answer-error" as const, error }), + ); + const finishAnswer = async (result: QuestionWaitAnswerResult) => { + if (result.status !== "pending") { + return result; + } + return (await cancel("wait-timeout")) ?? ({ status: "cancelled" } as const); + }; + if (bufferedAnswer) { + const terminal = await answerOutcome; + if (terminal.kind === "answer-error") { + throw terminal.error; + } + return await finishAnswer(terminal.result); + } + const beforeDelivery = await Promise.race([ + answerOutcome, + new Promise<{ kind: "delivery-ready" }>((resolve) => { + setTimeout(() => resolve({ kind: "delivery-ready" }), 0); + }), + ]); + if (beforeDelivery.kind === "answer") { + return await finishAnswer(beforeDelivery.result); + } + if (beforeDelivery.kind === "answer-error") { + throw beforeDelivery.error; + } + if (claim.isResolving()) { + // A registration-time text claim is already answering this question; a + // prompt delivered now would be stale before it renders. + const outcome = await answerOutcome; + if (outcome.kind === "answer-error") { + throw outcome.error; + } + return await finishAnswer(outcome.result); + } + const deliveryAbort = new AbortController(); + const delivery = deliverAgentHarnessQuestionPrompt( + params.delivery, + questionId, + params.questions, + params.promptOptions, + deliveryAbort.signal, + ); + const deliveryOutcome = delivery.then( + () => ({ kind: "delivery" as const }), + (error: unknown) => ({ kind: "delivery-error" as const, error }), + ); + const first = await Promise.race([answerOutcome, deliveryOutcome]); + if (first.kind === "answer") { + deliveryAbort.abort(new Error("gateway question resolved before prompt delivery")); + return await finishAnswer(first.result); + } + if (first.kind === "answer-error") { + deliveryAbort.abort(first.error); + throw first.error; + } + if (first.kind === "delivery-error") { + const terminal = await cancel("prompt-delivery-failed"); + if (terminal?.status === "answered") { + return terminal; + } + throw new Error("harness question prompt delivery failed", { cause: first.error }); + } + const terminal = await answerOutcome; + if (terminal.kind === "answer-error") { + throw terminal.error; + } + return await finishAnswer(terminal.result); + } catch (error) { + try { + const terminal = await cancel(params.signal?.aborted ? "run-abort" : "harness-error"); + if (terminal?.status === "answered") { + return terminal; + } + } catch { + // Preserve the original bridge failure. + } + if (params.signal?.aborted) { + return { status: "cancelled" }; + } + throw error; + } finally { + params.signal?.removeEventListener("abort", onAbort); + claim.dispose(); + } +} diff --git a/src/agents/harness/user-input-bridge.ts b/src/agents/harness/user-input-bridge.ts index 78974a3a4856..b7156af2280e 100644 --- a/src/agents/harness/user-input-bridge.ts +++ b/src/agents/harness/user-input-bridge.ts @@ -28,6 +28,13 @@ export type AgentHarnessUserInputPromptOptions = { presentation?: MessagePresentation; }; +type AgentHarnessQuestionPromptPayload = { + text: string; + presentation?: MessagePresentation; + presentationTextMode?: "fallback"; + channelData: { askUser: { questionId: string } }; +}; + type PromptDeliveryParams = Pick; export function emptyAgentHarnessUserInputAnswers(): AgentHarnessUserInputAnswers { @@ -78,6 +85,105 @@ export async function deliverAgentHarnessUserInputPrompt( await params.onPartialReply?.({ text }); } +/** Builds the portable one-question presentation shared by tools and harnesses. */ +function buildAgentHarnessQuestionPresentation(params: { + questionId: string; + questions: readonly AgentHarnessUserInputQuestion[]; + formatText?: (text: string) => string; +}): MessagePresentation | undefined { + // Button taps resolve atomically, so v1 keeps multi-question records text-only. + if (params.questions.length !== 1) { + return undefined; + } + const [question] = params.questions; + const options = question?.options ?? []; + const formatText = params.formatText ?? ((text: string) => text); + if (!question || question.multiSelect || question.isSecret || options.length === 0) { + return undefined; + } + // The question stays in its own leading text block so reaction/native + // adapters can keep it while replacing the tap-only guidance below. + const optionGuidance = [ + ...options.map( + (option) => + `- ${formatText(option.label)}${option.description ? `: ${formatText(option.description)}` : ""}`, + ), + "", + question.isOther + ? "Tap an option, or reply with the option text or your own answer." + : "Tap an option, or reply with the option number or text.", + ].join("\n"); + return { + blocks: [ + { type: "text", text: formatText(question.question) }, + { type: "text", text: optionGuidance }, + { + type: "buttons", + buttons: options.map((option) => ({ + label: formatText(option.label), + action: { + type: "question", + questionId: params.questionId, + optionValue: option.label, + }, + })), + }, + ], + }; +} + +/** Builds the exact question payload consumed by web chat and native channels. */ +export function buildAgentHarnessQuestionPromptPayload(params: { + questionId: string; + questions: readonly AgentHarnessUserInputQuestion[]; + options?: AgentHarnessUserInputPromptOptions; +}): AgentHarnessQuestionPromptPayload { + const prompt = formatAgentHarnessUserInputPrompt(params.questions, params.options); + // Callers may supply a fully-authored presentation; only build one otherwise. + const presentation = + params.options?.presentation ?? + buildAgentHarnessQuestionPresentation({ + ...params, + formatText: params.options?.formatText, + }); + return { + text: `${prompt}\n\n${questionReplyGuidance(params.questions)}`, + ...(presentation ? { presentation, presentationTextMode: "fallback" as const } : {}), + channelData: { askUser: { questionId: params.questionId } }, + }; +} + +function questionReplyGuidance(questions: readonly AgentHarnessUserInputQuestion[]): string { + if (questions.length !== 1) { + return "Reply by number or question id. Use a declared option where choices are fixed."; + } + const [question] = questions; + if (!question || (question.options?.length ?? 0) === 0) { + return "Reply with your answer."; + } + return question.isOther + ? "Reply with the number, the option text, or your own answer." + : "Reply with the number or option text."; +} + +/** Delivers a gateway-backed question through the harness block-reply surface. */ +export async function deliverAgentHarnessQuestionPrompt( + params: PromptDeliveryParams, + questionId: string, + questions: readonly AgentHarnessUserInputQuestion[], + options?: AgentHarnessUserInputPromptOptions, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const payload = buildAgentHarnessQuestionPromptPayload({ questionId, questions, options }); + if (params.onBlockReply) { + await params.onBlockReply(payload, signal ? { abortSignal: signal } : undefined); + return; + } + signal?.throwIfAborted(); + await params.onPartialReply?.({ text: payload.text }); +} + export function buildAgentHarnessUserInputAnswers( questions: readonly AgentHarnessUserInputQuestion[], inputText: string, diff --git a/src/agents/tools/ask-user-tool.test.ts b/src/agents/tools/ask-user-tool.test.ts index 7eee82f1967e..5f02abc06296 100644 --- a/src/agents/tools/ask-user-tool.test.ts +++ b/src/agents/tools/ask-user-tool.test.ts @@ -308,6 +308,55 @@ describe("ask_user execution", () => { expect(isAskUserPromptActive(reservation.questionId)).toBe(false); }); + it("suppresses a stale prompt when an image arrives during registration", async () => { + let finishRegistration: ((value: unknown) => void) | undefined; + let resolveCount = 0; + const gateway = gatewayStub(async (method) => { + if (method === "question.request") { + return await new Promise((resolve) => { + finishRegistration = resolve; + }); + } + if (method === "question.resolve") { + resolveCount += 1; + if (resolveCount === 1) { + throw Object.assign(new Error("not registered yet"), { + name: "GatewayClientRequestError", + details: { reason: "QUESTION_NOT_FOUND" }, + }); + } + return { status: "cancelled" }; + } + throw new Error(`unexpected method ${method}`); + }); + const sessionKey = "agent:main:register-image"; + const pending = createAskUserTool({ sessionKey, gatewayCall: gateway.call }).execute( + "call-register-image", + validArgs, + ); + await vi.waitFor(() => expect(finishRegistration).toBeTypeOf("function")); + const questionId = requestedQuestionId(gateway.mock); + const steer = vi.fn(async () => undefined); + const images = [{ type: "image" as const, data: "pixels", mimeType: "image/png" }]; + + await steerActiveSessionWithOptionalDeliveryWait( + { steer, subscribe: vi.fn(() => () => undefined) }, + "Use this image", + { isInboundUserMessage: true, images }, + sessionKey, + ); + finishRegistration?.({ id: questionId }); + + expect(steer).toHaveBeenCalledWith("Use this image", images); + await expect(pending).resolves.toMatchObject({ details: { status: "no_answer" } }); + expect( + gateway.mock.mock.calls.filter(([method]) => method === "question.resolve"), + ).toHaveLength(2); + expect(gateway.mock.mock.calls.some(([method]) => method === "question.waitAnswer")).toBe( + false, + ); + }); + it("best-effort cancels a deterministic id after an ambiguous registration failure", async () => { const sessionKey = "agent:main:registration-loss"; const gateway = gatewayStub(async (method) => { @@ -539,11 +588,16 @@ describe("ask_user execution", () => { }); } if (method === "question.resolve") { - if (cancelFails) { - throw new Error("gateway unavailable"); + if ("cancel" in params) { + if (cancelFails) { + throw new Error("gateway unavailable"); + } + finishWait?.({ status: "cancelled" }); + return { status: "cancelled" }; } - finishWait?.({ status: "cancelled" }); - return { status: "cancelled" }; + const result = { status: "answered", answers: params.answers }; + finishWait?.(result); + return result; } throw new Error(`unexpected method ${method}`); }); @@ -576,9 +630,18 @@ describe("ask_user execution", () => { }, ); if (cancelFails) { - finishWait?.({ status: "cancelled" }); + const followUpSteer = vi.fn(async () => undefined); + await steerActiveSessionWithOptionalDeliveryWait( + { steer: followUpSteer, subscribe: vi.fn(() => () => undefined) }, + "Follow-up after image", + { isInboundUserMessage: true }, + sessionKey, + ); + expect(followUpSteer).not.toHaveBeenCalled(); } - await pending; + await expect(pending).resolves.toMatchObject({ + details: { status: cancelFails ? "answered" : "no_answer" }, + }); }); it("confirms a committed plain-text answer after its resolve response is lost", async () => { diff --git a/src/agents/tools/ask-user-tool.ts b/src/agents/tools/ask-user-tool.ts index 4a0f5ebb7386..e30fcb52e2e7 100644 --- a/src/agents/tools/ask-user-tool.ts +++ b/src/agents/tools/ask-user-tool.ts @@ -7,10 +7,7 @@ import type { QuestionRequestQuestion, QuestionWaitAnswerResult, } from "../../../packages/gateway-protocol/src/index.js"; -import { - buildAgentHarnessUserInputAnswers, - type AgentHarnessUserInputQuestion, -} from "../harness/user-input-bridge.js"; +import { registerPendingAgentQuestion } from "../harness/gateway-question.js"; import { ASK_USER_TOOL_DISPLAY_SUMMARY, describeAskUserTool } from "../tool-description-presets.js"; import { type AnyAgentTool, ToolInputError, textResult } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; @@ -86,6 +83,7 @@ type AskUserQuestionState = { phase: AskUserQuestionPhase; gatewayCall?: AskUserGatewayCall; answer?: Promise; + claim?: ReturnType; waiters: Set<() => void>; }; @@ -226,6 +224,7 @@ function releaseAskUserQuestion(questionId: string): void { return; } askUserQuestions.delete(questionId); + state.claim?.dispose(); for (const wake of state.waiters) { wake(); } @@ -384,117 +383,6 @@ function isTerminalQuestionResolveError(error: unknown): boolean { return reason !== undefined && TERMINAL_QUESTION_ERROR_REASONS.has(reason); } -async function observeCommittedAnswer( - answer: Promise | undefined, -): Promise { - if (!answer) { - return false; - } - let timer: ReturnType | undefined; - try { - const result = await Promise.race([ - answer, - new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), 1_000); - timer.unref?.(); - }), - ]); - return result?.status === "answered"; - } catch { - return false; - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -/** Claims the next queued plain-text message for this session's active question. */ -export async function claimPendingAskUserAnswer(params: { - sessionKey?: string; - text: string; - persist?: () => Promise; -}): Promise { - const sessionKey = params.sessionKey?.trim(); - if (!sessionKey) { - return false; - } - const state = findAskUserQuestionForSession(sessionKey); - if (!state || state.phase.kind !== "answerable" || !state.gatewayCall) { - return false; - } - transitionAskUserQuestion(state, { kind: "resolving" }); - try { - await params.persist?.(); - } catch (error) { - transitionAskUserQuestion(state, { kind: "answerable" }); - throw error; - } - const answers = buildAgentHarnessUserInputAnswers( - state.questions as AgentHarnessUserInputQuestion[], - params.text, - ); - try { - await state.gatewayCall( - "question.resolve", - {}, - { id: state.questionId, answers, resolvedBy: "plain-text" }, - ); - return true; - } catch (error) { - if (isTerminalQuestionResolveError(error)) { - return false; - } - // The long-lived wait observes a resolve that committed even when its response was lost. - // Reusing it avoids a second gateway read and answer-shape comparison. - if (await observeCommittedAnswer(state.answer)) { - return true; - } - transitionAskUserQuestion(state, { kind: "answerable" }); - throw error; - } -} - -/** Cancels the blocking question before an inbound turn takes another route. */ -export async function cancelPendingAskUserForSession(params: { - sessionKey?: string; - resolvedBy: string; -}): Promise { - const sessionKey = params.sessionKey?.trim(); - if (!sessionKey) { - return false; - } - const state = findAskUserQuestionForSession(sessionKey); - if (!state || state.phase.kind === "reserved" || !state.gatewayCall) { - return false; - } - while (state.phase.kind === "registering") { - await waitForQuestionChange(state); - if (askUserQuestions.get(state.questionId) !== state) { - return false; - } - } - if (state.phase.kind === "resolving" || state.phase.kind === "prompt-failed") { - return false; - } - const previousPhase = state.phase; - transitionAskUserQuestion(state, { kind: "resolving" }); - try { - await state.gatewayCall( - "question.resolve", - { timeoutMs: ASK_USER_RPC_GRACE_MS }, - { id: state.questionId, cancel: true, resolvedBy: params.resolvedBy }, - ); - return true; - } catch (error) { - if (isTerminalQuestionResolveError(error)) { - return true; - } - transitionAskUserQuestion(state, previousPhase); - throw error; - } -} - function resetPendingAskUserQuestionsForTest(): void { for (const questionId of askUserQuestions.keys()) { releaseAskUserQuestion(questionId); @@ -615,22 +503,49 @@ export function createAskUserTool(params: { }; try { - const requestResult = (await gatewayCall( - "question.request", - {}, - { - id: questionId, - questions: normalized.questions, - ...(params.agentId ? { agentId: params.agentId } : {}), - ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), - timeoutMs, + state.claim = registerPendingAgentQuestion({ + questionId, + sessionKey, + questions: normalized.questions, + gatewayCall, + onCancel: () => { + if ( + askUserQuestions.get(questionId) === state && + state.phase.kind !== "reserved" && + state.phase.kind !== "resolving" && + state.phase.kind !== "prompt-failed" + ) { + transitionAskUserQuestion(state, { kind: "resolving" }); + } }, - signal ? { signal } : undefined, - )) as { id?: unknown }; + }); + const registration = Promise.resolve().then( + () => + gatewayCall( + "question.request", + {}, + { + id: questionId, + questions: normalized.questions, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + timeoutMs, + }, + signal ? { signal } : undefined, + ) as Promise<{ id?: unknown }>, + ); + state.claim.attachRegistration(registration); + const requestResult = await registration; registered = true; if (requestResult.id !== questionId) { throw new Error("question.request returned an unexpected question id"); } + if (state.claim.isCancellationRequested()) { + const answered = await cancelPendingQuestion("superseded-input"); + return answered + ? answeredResult(normalized.questions, answered.answers) + : noAnswerResult("cancelled"); + } signal?.addEventListener("abort", cancelOnAbort, { once: true }); if (signal?.aborted) { cancelOnAbort(); @@ -643,9 +558,16 @@ export function createAskUserTool(params: { signal ? { signal } : undefined, ) as Promise; state.answer = answerPromise; - if (deliverPrompt) { + const bufferedAnswer = await state.claim.setAnswer(answerPromise); + if (bufferedAnswer) { + return await finishWait(await answerPromise); + } + if (deliverPrompt && !state.claim.isResolving()) { // Tool-start reserves the prompt, but only a committed Gateway record opens delivery. // This prevents channels from exposing a question ID that cannot accept an answer. + // A registration-time claim in flight suppresses delivery entirely: the + // user already answered, so a late prompt would be stale and the race + // below could stall on a delivery that never happens. markAskUserPromptReady(questionId, normalized.questions); const promptDeliveryPromise = waitForPromptDelivery(state, signal); const first = await Promise.race([ @@ -667,7 +589,7 @@ export function createAskUserTool(params: { } throw new Error("ask_user prompt delivery failed", { cause: deliveryResult.error }); } - } else { + } else if (!state.claim.isResolving()) { transitionAskUserQuestion(state, { kind: "answerable" }); } const result = await state.answer; diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 875c536cde79..825da49336aa 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -234,6 +234,7 @@ describe("registerQrCli", () => { roles: ["node", "operator"], scopes: [ "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/src/gateway/server-methods/device-pair-setup.test.ts b/src/gateway/server-methods/device-pair-setup.test.ts index c659a612c6da..5e8ea0cd6826 100644 --- a/src/gateway/server-methods/device-pair-setup.test.ts +++ b/src/gateway/server-methods/device-pair-setup.test.ts @@ -245,6 +245,7 @@ describe("device.pair.setupCode", () => { roles: ["node", "operator"], scopes: [ "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/src/gateway/server-methods/question.ts b/src/gateway/server-methods/question.ts index 28ec0eac4f60..ef1cafdf3523 100644 --- a/src/gateway/server-methods/question.ts +++ b/src/gateway/server-methods/question.ts @@ -13,6 +13,10 @@ import { validateQuestionResolveParams, validateQuestionWaitAnswerParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { + handleQuestionChannelRequested, + handleQuestionChannelResolved, +} from "../../infra/question-channel-runtime.js"; import { QuestionManager, QuestionManagerError, @@ -101,8 +105,12 @@ export function createQuestionHandlers(manager: QuestionManager): GatewayRequest ...(request.agentId ? { agentId: request.agentId } : {}), ...(request.sessionKey ? { sessionKey: request.sessionKey } : {}), timeoutMs: request.timeoutMs ?? DEFAULT_QUESTION_TIMEOUT_MS, - onResolved: (event) => context.broadcast("question.resolved", event), + onResolved: (event) => { + handleQuestionChannelResolved(event); + context.broadcast("question.resolved", event); + }, }); + handleQuestionChannelRequested(record); context.broadcast("question.requested", record); respond(true, { id: record.id, expiresAtMs: record.expiresAtMs }, undefined); } catch (error) { diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index f7553fbcb6cb..9ae865b56b02 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -1202,6 +1202,7 @@ export function registerControlUiAndPairingSuite(): void { expect(operatorHandoff?.scopes).toEqual([ "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", @@ -1224,6 +1225,7 @@ export function registerControlUiAndPairingSuite(): void { expect(paired?.approvedScopes).toEqual([ "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", @@ -1234,6 +1236,7 @@ export function registerControlUiAndPairingSuite(): void { expect(paired?.tokens?.operator?.scopes).toEqual([ "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", @@ -1374,6 +1377,7 @@ export function registerControlUiAndPairingSuite(): void { expect(operatorHandoff?.scopes).toEqual([ "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", @@ -1389,6 +1393,7 @@ export function registerControlUiAndPairingSuite(): void { expect(paired?.approvedScopes).toEqual([ "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", @@ -1423,6 +1428,7 @@ export function registerControlUiAndPairingSuite(): void { } expect(operatorHandoff?.scopes).toEqual([ "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 5b51a276af5f..7e14c1210b28 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -64,7 +64,13 @@ describe("device bootstrap tokens", () => { issuedAtMs: Date.now(), profile: { roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + scopes: [ + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], }, }); }); @@ -154,7 +160,13 @@ describe("device bootstrap tokens", () => { await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( { roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + scopes: [ + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], }, ); await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull(); @@ -524,7 +536,13 @@ describe("device bootstrap tokens", () => { }), ).resolves.toEqual({ roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + scopes: [ + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], }); }); diff --git a/src/infra/question-channel-runtime-internal.ts b/src/infra/question-channel-runtime-internal.ts new file mode 100644 index 000000000000..b6eea12ea5fa --- /dev/null +++ b/src/infra/question-channel-runtime-internal.ts @@ -0,0 +1,154 @@ +import type { + QuestionRecord, + QuestionResolvedEvent, +} from "../../packages/gateway-protocol/src/schema/questions.js"; + +const TERMINAL_DELIVERY_RETENTION_MS = 24 * 60 * 60 * 1_000; + +type QuestionDeliveryFinalizer = (statusLine: string) => void | Promise; + +type QuestionChannelEntry = { + record?: QuestionRecord; + terminal?: QuestionResolvedEvent; + deliveries: Map; + finalizedDeliveryIds: Set; + cleanupTimer?: ReturnType; +}; + +type QuestionChannelRuntime = { + handleRequested: (record: QuestionRecord) => void; + handleResolved: (event: QuestionResolvedEvent) => void; + registerDelivery: (params: { + questionId: string; + deliveryId: string; + finalize: QuestionDeliveryFinalizer; + }) => void; + clear: () => void; +}; + +function collectAnsweredLabels( + record: QuestionRecord | undefined, + event: Extract, +): string[] { + if (!record) { + return []; + } + const answers = event.answers.answers; + return record.questions.flatMap((question) => { + // Only declared choices are safe to echo. Free-text answers can contain + // secrets, mentions, or transport markup, and the label filter below drops + // them; isOther alone must not suppress a declared selection. + if (question.isSecret || question.options.length === 0) { + return []; + } + const optionLabels = new Set(question.options.map((option) => option.label)); + return (answers[question.id]?.answers ?? []).filter((answer) => optionLabels.has(answer)); + }); +} + +function formatQuestionTerminalStatusLine(params: { + record?: QuestionRecord; + event: QuestionResolvedEvent; +}): string { + if (params.event.status === "expired") { + return "Expired"; + } + if (params.event.status === "cancelled") { + return "Cancelled"; + } + const labels = collectAnsweredLabels(params.record, params.event); + return labels.length > 0 ? `Answered: ${labels.join(", ")}` : "Answered"; +} + +export function createQuestionChannelRuntime( + options: { + onFinalizeError?: (error: unknown, questionId: string, deliveryId: string) => void; + terminalRetentionMs?: number; + } = {}, +): QuestionChannelRuntime { + const entries = new Map(); + const terminalRetentionMs = options.terminalRetentionMs ?? TERMINAL_DELIVERY_RETENTION_MS; + + const getOrCreate = (questionId: string): QuestionChannelEntry => { + const existing = entries.get(questionId); + if (existing) { + return existing; + } + const created: QuestionChannelEntry = { + deliveries: new Map(), + finalizedDeliveryIds: new Set(), + }; + entries.set(questionId, created); + return created; + }; + + const finalizeDelivery = ( + questionId: string, + entry: QuestionChannelEntry, + deliveryId: string, + finalize: QuestionDeliveryFinalizer, + ) => { + if (!entry.terminal || entry.finalizedDeliveryIds.has(deliveryId)) { + return; + } + entry.deliveries.delete(deliveryId); + entry.finalizedDeliveryIds.add(deliveryId); + const statusLine = formatQuestionTerminalStatusLine({ + record: entry.record, + event: entry.terminal, + }); + try { + void Promise.resolve(finalize(statusLine)).catch((error: unknown) => + options.onFinalizeError?.(error, questionId, deliveryId), + ); + } catch (error) { + options.onFinalizeError?.(error, questionId, deliveryId); + } + }; + + const scheduleCleanup = (questionId: string, entry: QuestionChannelEntry) => { + if (entry.cleanupTimer) { + return; + } + entry.cleanupTimer = setTimeout(() => { + if (entries.get(questionId) === entry) { + entries.delete(questionId); + } + }, terminalRetentionMs); + entry.cleanupTimer.unref?.(); + }; + + return { + handleRequested(record) { + const entry = getOrCreate(record.id); + entry.record = record; + }, + handleResolved(event) { + const entry = getOrCreate(event.id); + if (entry.terminal) { + return; + } + entry.terminal = event; + for (const [deliveryId, finalize] of entry.deliveries) { + finalizeDelivery(event.id, entry, deliveryId, finalize); + } + scheduleCleanup(event.id, entry); + }, + registerDelivery({ questionId, deliveryId, finalize }) { + const entry = getOrCreate(questionId); + if (entry.finalizedDeliveryIds.has(deliveryId)) { + return; + } + entry.deliveries.set(deliveryId, finalize); + finalizeDelivery(questionId, entry, deliveryId, finalize); + }, + clear() { + for (const entry of entries.values()) { + if (entry.cleanupTimer) { + clearTimeout(entry.cleanupTimer); + } + } + entries.clear(); + }, + }; +} diff --git a/src/infra/question-channel-runtime.test.ts b/src/infra/question-channel-runtime.test.ts new file mode 100644 index 000000000000..11d65f1a14e2 --- /dev/null +++ b/src/infra/question-channel-runtime.test.ts @@ -0,0 +1,139 @@ +// Covers question message finalization lifecycle and delivery races. +import { describe, expect, it, vi } from "vitest"; +import type { QuestionRecord } from "../../packages/gateway-protocol/src/schema/questions.js"; +import { createQuestionChannelRuntime } from "./question-channel-runtime-internal.js"; + +const record: QuestionRecord = { + id: "ask_0123456789abcdef0123456789abcdef", + status: "pending", + questions: [ + { + id: "target", + header: "Target", + question: "Deploy where?", + options: [{ label: "Staging" }, { label: "Production" }], + }, + ], + createdAtMs: 1, + expiresAtMs: 2, +}; + +describe("question channel runtime", () => { + it("finalizes delivered messages once with canonical answer labels", async () => { + const finalize = vi.fn(); + const runtime = createQuestionChannelRuntime(); + runtime.handleRequested(record); + runtime.registerDelivery({ questionId: record.id, deliveryId: "telegram:1", finalize }); + + const event = { + id: record.id, + status: "answered" as const, + answers: { answers: { target: { answers: ["Production"] } } }, + }; + runtime.handleResolved(event); + runtime.handleResolved(event); + await vi.waitFor(() => expect(finalize).toHaveBeenCalledOnce()); + expect(finalize).toHaveBeenCalledWith("Answered: Production"); + runtime.clear(); + }); + + it("finalizes expiry delivered after the terminal event", async () => { + const finalize = vi.fn(); + const runtime = createQuestionChannelRuntime(); + runtime.handleRequested(record); + runtime.handleResolved({ id: record.id, status: "expired" }); + runtime.registerDelivery({ questionId: record.id, deliveryId: "slack:1", finalize }); + + await vi.waitFor(() => expect(finalize).toHaveBeenCalledOnce()); + expect(finalize).toHaveBeenCalledWith("Expired"); + runtime.clear(); + }); + + it("does not echo free-text answers", async () => { + const finalize = vi.fn(); + const runtime = createQuestionChannelRuntime(); + runtime.handleRequested({ + ...record, + questions: [{ ...record.questions[0]!, options: [], isOther: true }], + }); + runtime.registerDelivery({ questionId: record.id, deliveryId: "telegram:text", finalize }); + runtime.handleResolved({ + id: record.id, + status: "answered", + answers: { answers: { target: { answers: ["@everyone secret-ish text"] } } }, + }); + + await vi.waitFor(() => expect(finalize).toHaveBeenCalledWith("Answered")); + runtime.clear(); + }); + + it("retains terminal state beyond the gateway grace for late delivery capture", async () => { + vi.useFakeTimers(); + try { + const finalize = vi.fn(); + const runtime = createQuestionChannelRuntime(); + runtime.handleRequested(record); + runtime.handleResolved({ id: record.id, status: "expired" }); + await vi.advanceTimersByTimeAsync(15_001); + runtime.registerDelivery({ questionId: record.id, deliveryId: "slack:late", finalize }); + + expect(finalize).toHaveBeenCalledWith("Expired"); + runtime.clear(); + } finally { + vi.useRealTimers(); + } + }); + + it("reports finalizer failures without retrying a double resolve", async () => { + const error = new Error("edit failed"); + const onFinalizeError = vi.fn(); + const runtime = createQuestionChannelRuntime({ onFinalizeError }); + runtime.handleRequested(record); + runtime.registerDelivery({ + questionId: record.id, + deliveryId: "discord:1", + finalize: () => { + throw error; + }, + }); + runtime.handleResolved({ id: record.id, status: "cancelled" }); + runtime.handleResolved({ id: record.id, status: "cancelled" }); + + await vi.waitFor(() => expect(onFinalizeError).toHaveBeenCalledOnce()); + expect(onFinalizeError).toHaveBeenCalledWith(error, record.id, "discord:1"); + runtime.clear(); + }); +}); + +describe("terminal status labels", () => { + it("echoes declared option answers even when free-text input was allowed", async () => { + const recordWithOther: QuestionRecord = { + id: "ask_q", + questions: [ + { + id: "deploy", + header: "Deploy", + question: "Where?", + options: [{ label: "Staging" }, { label: "Production" }], + isOther: true, + }, + ], + createdAtMs: 1, + expiresAtMs: 2, + status: "answered", + answers: { answers: { deploy: { answers: ["Staging"] } } }, + }; + const finalize = vi.fn(); + const runtime = createQuestionChannelRuntime(); + runtime.handleRequested(recordWithOther); + runtime.registerDelivery({ questionId: recordWithOther.id, deliveryId: "test:1", finalize }); + runtime.handleResolved({ + id: "ask_q", + status: "answered", + answers: { answers: { deploy: { answers: ["Staging"] } } }, + }); + + await vi.waitFor(() => expect(finalize).toHaveBeenCalledWith("Answered: Staging")); + runtime.clear(); + }); +}); diff --git a/src/infra/question-channel-runtime.ts b/src/infra/question-channel-runtime.ts new file mode 100644 index 000000000000..d6f2d632c9c8 --- /dev/null +++ b/src/infra/question-channel-runtime.ts @@ -0,0 +1,16 @@ +// Tracks delivered native question controls until the Gateway resolves them. +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { createQuestionChannelRuntime } from "./question-channel-runtime-internal.js"; + +const log = createSubsystemLogger("gateway/questions"); +const questionChannelRuntime = createQuestionChannelRuntime({ + onFinalizeError: (error, questionId, deliveryId) => { + log.warn(`question message finalization failed id=${questionId} delivery=${deliveryId}`, { + error: String(error), + }); + }, +}); + +export const handleQuestionChannelRequested = questionChannelRuntime.handleRequested; +export const handleQuestionChannelResolved = questionChannelRuntime.handleResolved; +export const registerQuestionChannelDelivery = questionChannelRuntime.registerDelivery; diff --git a/src/infra/question-gateway-resolver.test.ts b/src/infra/question-gateway-resolver.test.ts index a32223923a16..8392d5e22097 100644 --- a/src/infra/question-gateway-resolver.test.ts +++ b/src/infra/question-gateway-resolver.test.ts @@ -1,4 +1,4 @@ -// Covers compact question-button resolution through a stubbed Gateway. +// Covers question-button value resolution through a stubbed Gateway. import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveQuestionOverGateway } from "./question-gateway-resolver.js"; @@ -34,7 +34,7 @@ describe("resolveQuestionOverGateway", () => { hoisted.callGateway.mockReset(); }); - it("maps the compact option index to the canonical question id and label", async () => { + it("maps the rendered option value to the canonical question id", async () => { hoisted.callGateway.mockResolvedValueOnce({ question: pendingRecord }).mockResolvedValueOnce({ status: "answered", answers: { answers: { deploy_target: { answers: ["Production"] } } }, @@ -44,7 +44,7 @@ describe("resolveQuestionOverGateway", () => { resolveQuestionOverGateway({ cfg: {} as never, questionId: recordId, - optionIndex: 1, + optionValue: "Production", senderId: "telegram:42", }), ).resolves.toEqual({ @@ -85,7 +85,11 @@ describe("resolveQuestionOverGateway", () => { hoisted.callGateway.mockRejectedValueOnce(terminalError(reason)); await expect( - resolveQuestionOverGateway({ cfg: {} as never, questionId: recordId, optionIndex: 0 }), + resolveQuestionOverGateway({ + cfg: {} as never, + questionId: recordId, + optionValue: "Staging", + }), ).resolves.toEqual({ status: "already-terminal", reason: expectedReason }); }, ); @@ -96,18 +100,24 @@ describe("resolveQuestionOverGateway", () => { }); await expect( - resolveQuestionOverGateway({ cfg: {} as never, questionId: recordId, optionIndex: 0 }), + resolveQuestionOverGateway({ + cfg: {} as never, + questionId: recordId, + optionValue: "Staging", + }), ).resolves.toEqual({ status: "already-terminal", reason: "already-terminal" }); expect(hoisted.callGateway).toHaveBeenCalledOnce(); }); - it("rejects invalid indices before resolving", async () => { - hoisted.callGateway.mockResolvedValueOnce({ question: pendingRecord }); + it("leaves option membership validation to question.resolve", async () => { + hoisted.callGateway + .mockResolvedValueOnce({ question: pendingRecord }) + .mockRejectedValueOnce(new Error("invalid answer")); await expect( - resolveQuestionOverGateway({ cfg: {} as never, questionId: recordId, optionIndex: 2 }), - ).rejects.toThrow("out of range"); - expect(hoisted.callGateway).toHaveBeenCalledOnce(); + resolveQuestionOverGateway({ cfg: {} as never, questionId: recordId, optionValue: "Other" }), + ).rejects.toThrow("invalid answer"); + expect(hoisted.callGateway).toHaveBeenCalledTimes(2); }); it("never partially resolves a multi-question record", async () => { @@ -127,7 +137,11 @@ describe("resolveQuestionOverGateway", () => { }); await expect( - resolveQuestionOverGateway({ cfg: {} as never, questionId: recordId, optionIndex: 0 }), + resolveQuestionOverGateway({ + cfg: {} as never, + questionId: recordId, + optionValue: "Staging", + }), ).rejects.toThrow("one tappable question"); expect(hoisted.callGateway).toHaveBeenCalledOnce(); }); diff --git a/src/infra/question-gateway-resolver.ts b/src/infra/question-gateway-resolver.ts index 4e08e753791b..be6a81e0f9ce 100644 --- a/src/infra/question-gateway-resolver.ts +++ b/src/infra/question-gateway-resolver.ts @@ -15,11 +15,21 @@ export type ResolveQuestionOverGatewayResult = export type ResolveQuestionOverGatewayParams = { cfg: OpenClawConfig; questionId: string; - optionIndex: number; senderId?: string | null; gatewayUrl?: string; clientDisplayName?: string; -}; +} & ( + | { + /** Rendered option value carried by the pressed control (reactions). */ + optionValue: string; + optionIndex?: never; + } + | { + /** Compact callback index; mapped to the canonical label via question.get. */ + optionIndex: number; + optionValue?: never; + } +); function readTerminalReason(error: unknown): "already-terminal" | "not-found" | undefined { if (!(error instanceof Error) || error.name !== "GatewayClientRequestError") { @@ -36,15 +46,18 @@ function readTerminalReason(error: unknown): "already-terminal" | "not-found" | return reason === "QUESTION_NOT_FOUND" ? "not-found" : undefined; } -/** Maps a compact option index to the canonical label, then resolves the question atomically. */ +/** Resolves one rendered option value against the gateway-owned question. */ export async function resolveQuestionOverGateway( params: ResolveQuestionOverGatewayParams, ): Promise { if (!QUESTION_RECORD_ID_PATTERN.test(params.questionId)) { throw new Error("question resolution requires a valid question record id"); } - if (!Number.isInteger(params.optionIndex) || params.optionIndex < 0) { - throw new Error("question resolution requires a valid option index"); + if (params.optionValue === undefined && !Number.isInteger(params.optionIndex)) { + throw new Error("question resolution requires an option value or index"); + } + if (params.optionValue !== undefined && !params.optionValue) { + throw new Error("question resolution requires a non-empty option value"); } const gatewayOptions = { config: params.cfg, @@ -76,11 +89,10 @@ export async function resolveQuestionOverGateway( if (!question || question.multiSelect || question.isSecret) { throw new Error("question button resolution requires one tappable question"); } - const optionValue = question.options[params.optionIndex]?.label; + const optionValue = params.optionValue ?? question.options[params.optionIndex as number]?.label; if (!optionValue) { - throw new Error("question resolution option index is out of range"); + throw new Error("question resolution index does not match a declared option"); } - try { await callGateway({ ...gatewayOptions, diff --git a/src/infra/question-reaction-runtime.test.ts b/src/infra/question-reaction-runtime.test.ts new file mode 100644 index 000000000000..c03c8763caec --- /dev/null +++ b/src/infra/question-reaction-runtime.test.ts @@ -0,0 +1,132 @@ +// Covers portable question-to-reaction rendering and emoji dispatch. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + prepareQuestionReactionPayloadForDelivery, + readQuestionReactionBinding, + resolveQuestionReactionOverGateway, +} from "./question-reaction-runtime.js"; + +const hoisted = vi.hoisted(() => ({ callGateway: vi.fn() })); +vi.mock("../gateway/call.js", () => ({ callGateway: hoisted.callGateway })); + +const questionId = "ask_0123456789abcdef0123456789abcdef"; +const presentation = { + blocks: [ + { type: "text" as const, text: "Deploy where?" }, + { + type: "buttons" as const, + buttons: ["Staging", "Production"].map((label) => ({ + label, + action: { type: "question" as const, questionId, optionValue: label }, + })), + }, + ], +}; + +describe("question reaction runtime", () => { + beforeEach(() => hoisted.callGateway.mockReset()); + + it("renders numbered choices and carries a typed delivery binding", () => { + const payload = prepareQuestionReactionPayloadForDelivery({ + payload: { channelData: { askUser: { questionId } }, presentation }, + presentation, + }); + expect(payload?.text).toBe("Deploy where?\n\nReact with:\n1️⃣ Staging\n2️⃣ Production"); + expect(payload?.presentation).toBeUndefined(); + expect(readQuestionReactionBinding(payload ?? {})).toEqual({ + questionId, + optionValues: ["Staging", "Production"], + }); + }); + + it("keeps a one-option prompt reaction-eligible", () => { + const singleOptionPresentation = { + ...presentation, + blocks: [ + presentation.blocks[0]!, + { + type: "buttons" as const, + buttons: [ + { + label: "Staging", + action: { type: "question" as const, questionId, optionValue: "Staging" }, + }, + ], + }, + ], + }; + const payload = prepareQuestionReactionPayloadForDelivery({ + payload: { + channelData: { askUser: { questionId } }, + presentation: singleOptionPresentation, + }, + presentation: singleOptionPresentation, + }); + + expect(readQuestionReactionBinding(payload ?? {})).toEqual({ + questionId, + optionValues: ["Staging"], + }); + }); + + it("resolves a reordered presentation by its rendered option value", async () => { + const reorderedPresentation = { + ...presentation, + blocks: [ + presentation.blocks[0]!, + { + type: "buttons" as const, + buttons: ["Production", "Staging"].map((optionValue) => ({ + label: optionValue, + action: { type: "question" as const, questionId, optionValue }, + })), + }, + ], + }; + const payload = prepareQuestionReactionPayloadForDelivery({ + payload: { channelData: { askUser: { questionId } }, presentation: reorderedPresentation }, + presentation: reorderedPresentation, + }); + const binding = readQuestionReactionBinding(payload ?? {}); + expect(binding?.optionValues).toEqual(["Production", "Staging"]); + hoisted.callGateway + .mockResolvedValueOnce({ + question: { + id: questionId, + status: "pending", + questions: [ + { + id: "target", + header: "Target", + question: "Deploy where?", + options: [{ label: "Staging" }, { label: "Production" }], + }, + ], + createdAtMs: 1, + expiresAtMs: 2, + }, + }) + .mockResolvedValueOnce({ status: "answered" }); + + await expect( + resolveQuestionReactionOverGateway({ + cfg: {} as never, + questionId, + optionValue: binding!.optionValues[0]!, + senderId: "signal:+15550001111", + }), + ).resolves.toEqual({ + status: "answered", + questionId: "target", + optionValue: "Production", + }); + expect(hoisted.callGateway.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + method: "question.resolve", + params: expect.objectContaining({ + answers: { answers: { target: { answers: ["Production"] } } }, + }), + }), + ); + }); +}); diff --git a/src/infra/question-reaction-runtime.ts b/src/infra/question-reaction-runtime.ts new file mode 100644 index 000000000000..53ca7f952009 --- /dev/null +++ b/src/infra/question-reaction-runtime.ts @@ -0,0 +1,115 @@ +// Converts eligible portable question buttons into numbered reaction choices. +import type { ReplyPayload } from "../auto-reply/reply-payload.js"; +import type { MessagePresentation } from "../interactive/payload.js"; +import { renderMessagePresentationFallbackText } from "../interactive/payload.js"; +import { + resolveQuestionOverGateway, + type ResolveQuestionOverGatewayParams, + type ResolveQuestionOverGatewayResult, +} from "./question-gateway-resolver.js"; + +const QUESTION_REACTION_CHANNEL_DATA_KEY = "openclawQuestionReaction"; + +export const QUESTION_REACTION_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣"] as const; + +type QuestionReactionBinding = { + questionId: string; + optionValues: string[]; +}; + +export function readAskUserQuestionId( + payload: Pick, +): string | undefined { + const askUser = payload.channelData?.askUser; + if (!askUser || typeof askUser !== "object" || Array.isArray(askUser)) { + return undefined; + } + const questionId = (askUser as { questionId?: unknown }).questionId; + return typeof questionId === "string" && questionId ? questionId : undefined; +} + +export function readQuestionReactionBinding( + payload: Pick, +): QuestionReactionBinding | undefined { + const raw = payload.channelData?.[QUESTION_REACTION_CHANNEL_DATA_KEY]; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const questionId = (raw as { questionId?: unknown }).questionId; + const optionValues = (raw as { optionValues?: unknown }).optionValues; + return typeof questionId === "string" && + questionId.length > 0 && + Array.isArray(optionValues) && + optionValues.length >= 1 && + optionValues.length <= QUESTION_REACTION_EMOJIS.length && + optionValues.every((value) => typeof value === "string" && value.length > 0) + ? { questionId, optionValues: [...optionValues] } + : undefined; +} + +export function resolveQuestionReactionIndex(reaction: string): number | undefined { + const index = QUESTION_REACTION_EMOJIS.indexOf( + reaction as (typeof QUESTION_REACTION_EMOJIS)[number], + ); + return index >= 0 ? index : undefined; +} + +export function prepareQuestionReactionPayloadForDelivery(params: { + payload: ReplyPayload; + presentation?: MessagePresentation; +}): ReplyPayload | null { + const questionId = readAskUserQuestionId(params.payload); + const presentation = params.presentation ?? params.payload.presentation; + if (!questionId || !presentation) { + return null; + } + const buttonBlocks = presentation.blocks.filter((block) => block.type === "buttons"); + if (buttonBlocks.length !== 1) { + return null; + } + const [buttonBlock] = buttonBlocks; + if (!buttonBlock || buttonBlock.buttons.length < 1 || buttonBlock.buttons.length > 4) { + return null; + } + const labels: string[] = []; + const optionValues: string[] = []; + for (const button of buttonBlock.buttons) { + if ( + button.action?.type !== "question" || + button.action.questionId !== questionId || + !button.action.optionValue + ) { + return null; + } + labels.push(button.label); + optionValues.push(button.action.optionValue); + } + // Keep only the leading question block: the second text block carries + // tap-oriented option guidance that is wrong for reaction channels, and the + // reaction hint below re-lists every option. + const questionBlock = presentation.blocks.find((block) => block.type === "text"); + const textPresentation: MessagePresentation = { + ...presentation, + blocks: questionBlock ? [questionBlock] : [], + }; + const prompt = renderMessagePresentationFallbackText({ presentation: textPresentation }); + const reactionHint = labels + .map((label, index) => `${QUESTION_REACTION_EMOJIS[index]} ${label}`) + .join("\n"); + return { + ...params.payload, + text: `${prompt}\n\nReact with:\n${reactionHint}`, + presentation: undefined, + presentationTextMode: undefined, + channelData: { + ...params.payload.channelData, + [QUESTION_REACTION_CHANNEL_DATA_KEY]: { questionId, optionValues }, + }, + }; +} + +export async function resolveQuestionReactionOverGateway( + params: ResolveQuestionOverGatewayParams, +): Promise { + return await resolveQuestionOverGateway(params); +} diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index e0dd532c47ed..ec07ba56ca6f 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -137,6 +137,7 @@ describe("pairing setup code", () => { scopes: [ "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 4e93d2b59d7e..99cf84a7adb4 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -61,6 +61,7 @@ export type { AgentHarnessUserInputPromptOptions, AgentHarnessUserInputQuestion, } from "../agents/harness/user-input-bridge.js"; +export type { AgentHarnessQuestionGatewayCall } from "../agents/harness/gateway-question.js"; export type EmbeddedRunAttemptParams = Omit; export type { EmbeddedRunAttemptResult }; export type { @@ -180,6 +181,11 @@ export { formatAgentHarnessUserInputPrompt, normalizeAgentHarnessUserInputAnswer, } from "../agents/harness/user-input-bridge.js"; +export { + cancelPendingAgentQuestionForSession, + claimPendingAgentQuestionAnswer, + runAgentHarnessGatewayQuestion, +} from "../agents/harness/gateway-question.js"; export { buildSkillWorkshopPromptSection, SKILL_WORKSHOP_TOOL_NAME, diff --git a/src/plugin-sdk/question-gateway-runtime.ts b/src/plugin-sdk/question-gateway-runtime.ts index 6ba9ccf8ca37..392c3bdce358 100644 --- a/src/plugin-sdk/question-gateway-runtime.ts +++ b/src/plugin-sdk/question-gateway-runtime.ts @@ -1,6 +1,24 @@ -/** Runtime SDK subpath for resolving ask_user question choices over the Gateway. */ -export { - resolveQuestionOverGateway, - type ResolveQuestionOverGatewayParams, - type ResolveQuestionOverGatewayResult, -} from "../infra/question-gateway-resolver.js"; +/** Runtime SDK subpath for Gateway-backed ask_user question controls. */ +import { registerQuestionChannelDelivery } from "../infra/question-channel-runtime.js"; +// The pre-release named resolver exports were replaced wholesale by this +// runtime object before any tagged release shipped them; no compat aliases. +import { resolveQuestionOverGateway } from "../infra/question-gateway-resolver.js"; +import { + QUESTION_REACTION_EMOJIS, + prepareQuestionReactionPayloadForDelivery, + readAskUserQuestionId, + readQuestionReactionBinding, + resolveQuestionReactionIndex, + resolveQuestionReactionOverGateway, +} from "../infra/question-reaction-runtime.js"; + +export const questionGatewayRuntime = { + resolveOption: resolveQuestionOverGateway, + reactionEmojis: QUESTION_REACTION_EMOJIS, + prepareReactionPayloadForDelivery: prepareQuestionReactionPayloadForDelivery, + readAskUserQuestionId, + readReactionBinding: readQuestionReactionBinding, + resolveReactionIndex: resolveQuestionReactionIndex, + resolveReaction: resolveQuestionReactionOverGateway, + registerChannelDelivery: registerQuestionChannelDelivery, +}; diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index b61e1af5947d..b3a3dd24d46c 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -106,6 +106,7 @@ describe("device bootstrap profile", () => { scopes: [ "operator.admin", "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", @@ -117,7 +118,13 @@ describe("device bootstrap profile", () => { test("existing setup profile preserves the bounded operator handoff", () => { expect(PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + scopes: [ + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], }); }); @@ -155,6 +162,7 @@ describe("device bootstrap profile", () => { test("bootstrap handoff operator allowlist stays bounded", () => { expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([ "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index 98d935e5ff2e..156fa1dfb7d2 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -21,6 +21,7 @@ export type DeviceBootstrapProfileInput = { /** Operator scopes allowed to cross the short-lived bootstrap handoff boundary. */ export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [ "operator.approvals", + "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/ui/src/app/question-prompt.test.ts b/ui/src/app/question-prompt.test.ts index 6b7386807e36..db4a97f24847 100644 --- a/ui/src/app/question-prompt.test.ts +++ b/ui/src/app/question-prompt.test.ts @@ -1,6 +1,7 @@ // Control UI tests cover operator question parsing and lifecycle state. import { afterEach, describe, expect, it, vi } from "vitest"; import { + cancelQuestionPrompt, createQuestionPromptState, disposeQuestionPromptState, handleQuestionPromptEvent, @@ -339,6 +340,23 @@ describe("question RPC helpers", () => { }, }); }); + + it("cancels a pending question when the docked panel is skipped", async () => { + const request = vi.fn(async () => ({})); + const state = createState(); + setQuestionPromptClient(state, { request }); + handleQuestionPromptEvent(state, { + event: "question.requested", + payload: requestedPayload(), + }); + + await cancelQuestionPrompt(state, "question-1"); + + expect(request).toHaveBeenCalledWith("question.resolve", { + id: "question-1", + cancel: true, + }); + }); }); describe("refreshPendingQuestions", () => { diff --git a/ui/src/app/question-prompt.ts b/ui/src/app/question-prompt.ts index 41c785b2b039..a0cbdac17910 100644 --- a/ui/src/app/question-prompt.ts +++ b/ui/src/app/question-prompt.ts @@ -1,4 +1,4 @@ -// Control UI module owns transient in-thread operator question state. +// Control UI module owns transient operator question state. import type { Question, QuestionAnswers, @@ -556,18 +556,10 @@ function buildAnswers(values: QuestionAnswerValues): QuestionAnswers { }; } -async function resolveQuestion( - client: QuestionClient, - id: string, - answers: QuestionAnswerValues, -): Promise { - await client.request("question.resolve", { id, answers: buildAnswers(answers) }); -} - -export async function submitQuestionPrompt( +async function resolveQuestionPrompt( state: QuestionPromptState, id: string, - answers: QuestionAnswerValues, + resolution: { answers: QuestionAnswerValues } | { cancel: true }, ): Promise { const prompt = state.prompts.get(id); const client = state.client; @@ -581,18 +573,22 @@ export async function submitQuestionPrompt( return; } prompt.submitting = true; - prompt.submittedAnswers = buildAnswers(answers); + const submittedAnswers = "answers" in resolution ? buildAnswers(resolution.answers) : undefined; + prompt.submittedAnswers = submittedAnswers; prompt.error = null; prompt.revision = ++state.revision; state.onChange(); try { - await resolveQuestion(client, id, answers); + await client.request( + "question.resolve", + submittedAnswers ? { id, answers: submittedAnswers } : { id, cancel: true }, + ); const current = state.prompts.get(id); if (!current) { return; } current.localResolutionConfirmed = true; - if (current.status === "answered") { + if (current.status !== "pending") { current.answeredElsewhere = false; current.submitting = false; } @@ -618,6 +614,18 @@ export async function submitQuestionPrompt( } } +export async function submitQuestionPrompt( + state: QuestionPromptState, + id: string, + answers: QuestionAnswerValues, +): Promise { + await resolveQuestionPrompt(state, id, { answers }); +} + +export async function cancelQuestionPrompt(state: QuestionPromptState, id: string): Promise { + await resolveQuestionPrompt(state, id, { cancel: true }); +} + export function listQuestionPrompts(state: QuestionPromptState): QuestionPrompt[] { return [...state.prompts.values()].toSorted( (left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id), diff --git a/ui/src/e2e/question-flow.e2e.test.ts b/ui/src/e2e/question-flow.e2e.test.ts index e769fe5b1ccb..c3695ff7d3ab 100644 --- a/ui/src/e2e/question-flow.e2e.test.ts +++ b/ui/src/e2e/question-flow.e2e.test.ts @@ -1,4 +1,4 @@ -// Control UI E2E tests cover Gateway question cards through the mocked WebSocket. +// Control UI E2E tests cover docked Gateway questions through the mocked WebSocket. import { mkdir } from "node:fs/promises"; import path from "node:path"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; @@ -58,6 +58,22 @@ async function screenshot(page: Page, name: string) { }); } +function historyMessages() { + return Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: [ + { + type: "text", + text: + index === 11 + ? "I have the release context ready. I only need your deployment choice." + : `Release preparation note ${index + 1}: deterministic transcript content for the docked panel proof.`, + }, + ], + timestamp: 1_750_000_000_000 + index * 1_000, + })); +} + async function openQuestionPage() { context = await browser.newContext({ locale: "en-US", @@ -66,6 +82,7 @@ async function openQuestionPage() { }); const page = await context.newPage(); const gateway = await installMockGateway(page, { + historyMessages: historyMessages(), methodResponses: { "question.list": { questions: [] }, }, @@ -76,8 +93,8 @@ async function openQuestionPage() { return { gateway, page }; } -function cardFor(page: Page, prompt: string) { - return page.locator("openclaw-chat-question").filter({ hasText: prompt }); +function panelFor(page: Page, prompt: string) { + return page.locator("openclaw-chat-question-panel").filter({ hasText: prompt }); } async function emitRequested( @@ -106,7 +123,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { await server?.close(); }); - it("renders and resolves a single-choice question in the active thread", async () => { + it("docks a pending question and replaces it with a compact answer summary", async () => { const { gateway, page } = await openQuestionPage(); const request = questionRecord("question-deploy-target", [ { @@ -128,22 +145,37 @@ describeControlUiE2e("Control UI Gateway question flow", () => { ]); await emitRequested(gateway, request); - const card = cardFor(page, "Where should I deploy?"); - await card.waitFor(); - await expect.poll(() => card.getByText("Deploy", { exact: true }).count()).toBe(1); + const panel = panelFor(page, "Where should I deploy?"); + await panel.waitFor(); await expect - .poll(() => card.getByText("Staging (Recommended)", { exact: true }).count()) - .toBe(1); - await expect.poll(() => card.getByText("Production", { exact: true }).count()).toBe(1); + .poll(() => page.locator(".chat-thread openclaw-chat-question-panel").count()) + .toBe(0); + await expect.poll(() => panel.getByText("1/1", { exact: true }).count()).toBe(1); + await expect.poll(() => panel.getByPlaceholder("Type your own answer here").count()).toBe(1); + await expect - .poll(() => card.getByRole("textbox", { name: "Your own answer for Deploy" }).count()) - .toBe(1); - await expect.poll(() => card.getByRole("button", { name: "Submit answer" }).count()).toBe(1); + .poll(async () => { + const panelBox = await panel.boundingBox(); + const composerBox = await page.locator(".agent-chat__input").boundingBox(); + if (!panelBox || !composerBox) { + return null; + } + return Math.round(composerBox.y - (panelBox.y + panelBox.height)); + }) + .toBeGreaterThanOrEqual(0); + await expect + .poll(async () => { + const panelHeight = (await panel.boundingBox())?.height ?? 0; + const padding = await page + .locator(".chat-thread") + .evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingBottom)); + return padding >= panelHeight; + }) + .toBe(true); await screenshot(page, "01-question-pending.png"); - const staging = card.locator('input[type="radio"]').first(); - await staging.check(); - await card.getByRole("button", { name: "Submit answer" }).click(); + await panel.getByRole("radio", { name: /Staging \(Recommended\)/ }).click(); + await panel.getByRole("button", { name: "Submit", exact: true }).click(); const resolveRequest = await gateway.waitForRequest("question.resolve"); expect(resolveRequest.params).toEqual({ id: request.id, @@ -163,13 +195,16 @@ describeControlUiE2e("Control UI Gateway question flow", () => { }, }, }); - await card.getByText("Answered", { exact: true }).waitFor(); - await expect.poll(() => staging.isChecked()).toBe(true); - await expect.poll(() => staging.isDisabled()).toBe(true); + await expect.poll(() => panel.count()).toBe(0); + const summary = page.locator(".chat-question-summary").filter({ hasText: "Deploy:" }); + await summary.waitFor(); + await expect + .poll(() => summary.getByText("Staging (Recommended)", { exact: true }).count()) + .toBe(1); await screenshot(page, "02-question-answered.png"); }); - it("submits multi-select answers as an array", async () => { + it("keeps multi-select on one step and submits labels as an array", async () => { const { gateway, page } = await openQuestionPage(); const request = questionRecord("question-release-checks", [ { @@ -187,16 +222,19 @@ describeControlUiE2e("Control UI Gateway question flow", () => { ]); await emitRequested(gateway, request); - const card = cardFor(page, "Which release checks should I run?"); - await card.waitFor(); - const options = card.locator('input[type="checkbox"]'); - await options.nth(0).check(); - await options.nth(2).check(); - await expect.poll(() => options.nth(0).isChecked()).toBe(true); - await expect.poll(() => options.nth(2).isChecked()).toBe(true); + const panel = panelFor(page, "Which release checks should I run?"); + await panel.waitFor(); + await panel.getByRole("checkbox", { name: /Tests/ }).click(); + await panel.getByRole("checkbox", { name: /Metrics/ }).click(); + await expect + .poll(() => panel.getByRole("checkbox", { name: /Tests/ }).getAttribute("aria-checked")) + .toBe("true"); + await expect + .poll(() => panel.getByRole("checkbox", { name: /Metrics/ }).getAttribute("aria-checked")) + .toBe("true"); await screenshot(page, "03-question-multiselect.png"); - await card.getByRole("button", { name: "Submit answer" }).click(); + await panel.getByRole("button", { name: "Submit", exact: true }).click(); const resolveRequest = await gateway.waitForRequest("question.resolve"); expect(resolveRequest.params).toEqual({ id: request.id, @@ -208,7 +246,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { }); }); - it("renders answered-elsewhere and expired terminal states", async () => { + it("shows a 1/2 stepper with answered and expired summaries", async () => { const { gateway, page } = await openQuestionPage(); const elsewhere = questionRecord("question-external-answer", [ { @@ -228,34 +266,47 @@ describeControlUiE2e("Control UI Gateway question flow", () => { ]); await emitRequested(gateway, elsewhere); - await emitRequested(gateway, expired); - const elsewhereCard = cardFor(page, "Who should approve the release?"); - const expiredCard = cardFor(page, "When should the release start?"); - await elsewhereCard.waitFor(); - await expiredCard.waitFor(); - await gateway.emitGatewayEvent("question.resolved", { id: elsewhere.id, status: "answered", - answers: { - answers: { - approval_path: { answers: ["Release manager"] }, - }, - }, + answers: { answers: { approval_path: { answers: ["Release manager"] } } }, }); - await elsewhereCard.getByText("Answered elsewhere", { exact: true }).waitFor(); - const elsewhereAnswer = elsewhereCard.locator('input[type="radio"]').nth(1); - await expect.poll(() => elsewhereAnswer.isChecked()).toBe(true); - await expect.poll(() => elsewhereAnswer.isDisabled()).toBe(true); - + await emitRequested(gateway, expired); await gateway.emitGatewayEvent("question.resolved", { id: expired.id, status: "expired", }); - await expiredCard.getByText("Expired", { exact: true }).waitFor(); + + const stepper = questionRecord("question-release-plan", [ + { + id: "channel", + header: "Channel", + question: "Which release channel should I use?", + options: [{ label: "Beta" }, { label: "Stable" }], + isOther: true, + }, + { + id: "notes", + header: "Notes", + question: "Which notes should I include?", + options: [{ label: "Highlights" }, { label: "Full details" }], + multiSelect: true, + isOther: true, + }, + ]); + await emitRequested(gateway, stepper); + + const panel = panelFor(page, "Which release channel should I use?"); + await panel.waitFor(); + await expect.poll(() => panel.getByText("1/2", { exact: true }).count()).toBe(1); await expect - .poll(() => expiredCard.locator('input[type="radio"]').first().isDisabled()) - .toBe(true); + .poll(() => + page.locator(".chat-question-summary").filter({ hasText: "Release manager" }).count(), + ) + .toBe(1); + await expect + .poll(() => page.locator(".chat-question-summary").filter({ hasText: "Expired" }).count()) + .toBe(1); await screenshot(page, "04-question-terminal-states.png"); }); }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index fff1bcfa2b2c..863f45db881e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3685,10 +3685,18 @@ export const en: TranslationMap = { }, questions: { title: "Codex needs input", - other: "Type another answer", - submit: "Submit answer", + other: "Type your own answer here", + submit: "Submit", + next: "Next", + back: "Back", + skip: "Skip", + skipping: "Skipping…", + skipped: "Skipped", + collapse: "Collapse question", + expand: "Expand question", eyebrow: "Question", cardLabel: "Question from the assistant", + summaryLabel: "Question outcome", timeRemaining: "Time remaining", ownAnswer: "Your own answer…", ownAnswerFor: "Your own answer for {header}", diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 77a740aa81eb..87634bc64050 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -56,7 +56,7 @@ export type ChatItem = } | { kind: "stream"; key: string; text: string; startedAt: number; isStreaming: boolean } | { kind: "reading-indicator"; key: string; startedAt: number } - | { kind: "question"; key: string; questionId: string; startedAt: number; pending: boolean } + | { kind: "question"; key: string; questionId: string; startedAt: number } | { kind: "plan"; key: string }; export type ChatStreamSegment = { diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index 754c22263e7f..e63d53bf3a0e 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -2,6 +2,7 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { QuestionPrompt } from "../../app/question-prompt.ts"; import { i18n, t } from "../../i18n/index.ts"; import { renderChatComposer, resetChatComposerState } from "./components/chat-composer.ts"; @@ -39,6 +40,32 @@ function renderComposer(overrides: Partial = {}) { return { container, props: composerProps }; } +function questionPrompt(id: string, question: string): QuestionPrompt { + return { + id, + questions: [ + { + id: "choice", + header: "Choice", + question, + options: [{ label: "Yes" }, { label: "No" }], + isOther: false, + }, + ], + sessionKey: "queue-test", + createdAtMs: 1_000, + expiresAtMs: Date.now() + 60_000, + status: "pending", + answeredElsewhere: false, + localResolutionConfirmed: false, + locallyExpired: false, + submitting: false, + error: null, + drafts: new Map(), + revision: 1, + }; +} + function button(container: Element, label: string): HTMLButtonElement { const result = container.querySelector(`button[aria-label="${label}"]`); if (!result) { @@ -223,6 +250,49 @@ describe("renderChatComposer controls", () => { }); describe("renderChatComposer status", () => { + it("keeps every concurrent gateway question reachable", async () => { + const container = document.createElement("div"); + const onRequestUpdate = vi.fn(); + const composerProps = props({ + sessionKey: "queue-test", + gatewayQuestionPrompts: [ + questionPrompt("question-1", "First prompt"), + questionPrompt("question-2", "Second prompt"), + ], + onRequestUpdate, + }); + + render(renderChatComposer(composerProps), container); + let panel = container.querySelector("openclaw-chat-question-panel") as HTMLElement & { + props: { + model: { questions: Array<{ question: string }>; requestPosition?: unknown }; + onNextRequest?: () => void; + }; + }; + expect(panel.props.model.questions[0]?.question).toBe("First prompt"); + expect(panel.props.model.requestPosition).toEqual({ current: 1, total: 2 }); + + panel.props.onNextRequest?.(); + expect(onRequestUpdate).toHaveBeenCalledOnce(); + render(renderChatComposer(composerProps), container); + panel = container.querySelector("openclaw-chat-question-panel") as typeof panel; + expect(panel.props.model.questions[0]?.question).toBe("Second prompt"); + expect(panel.props.model.requestPosition).toEqual({ current: 2, total: 2 }); + }); + + it("keeps unscoped and other-session gateway questions out of the composer", () => { + const unscopedPrompt = questionPrompt("question-1", "Unscoped prompt"); + unscopedPrompt.sessionKey = undefined; + const otherSessionPrompt = questionPrompt("question-2", "Other prompt"); + otherSessionPrompt.sessionKey = "agent:other:main"; + + const view = renderComposer({ + sessionKey: "queue-test", + gatewayQuestionPrompts: [unscopedPrompt, otherSessionPrompt], + }); + + expect(view.container.querySelector("openclaw-chat-question-panel")).toBeNull(); + }); it("renders only a fresh interrupted run as visible status chrome", () => { const now = vi.spyOn(Date, "now").mockReturnValue(1_000); let view = renderComposer({ diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 72101042284b..fc1b9037f833 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -31,6 +31,7 @@ import { } from "../../app/context.ts"; import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts"; import { + cancelQuestionPrompt, createQuestionPromptState, disposeQuestionPromptState, handleQuestionPromptEvent, @@ -2300,7 +2301,6 @@ class ChatPane extends OpenClawLightDomElement { compactionStatus: state.compactionStatus, fallbackStatus: state.fallbackStatus, planStatus: state.planStatus, - questionStatus: state.questionStatus, gatewayQuestionPrompts: catalogKey ? [] : this.questionPrompts, onGatewayQuestionChange: () => { this.questionPrompts = [...this.questionPrompts]; @@ -2308,6 +2308,7 @@ class ChatPane extends OpenClawLightDomElement { }, onGatewayQuestionSubmit: (id, answers) => submitQuestionPrompt(this.questionPromptState, id, answers), + onGatewayQuestionSkip: (id) => cancelQuestionPrompt(this.questionPromptState, id), messages: catalogKey ? this.catalogMessages : state.chatMessages, historyPagination: catalogKey || state.chatHistoryPagination?.hasMore || this.loadingOlder @@ -2464,11 +2465,6 @@ class ChatPane extends OpenClawLightDomElement { onQueueRetry: (id) => void state.retryQueuedChatMessage(id), onQueueSteer: (id) => void state.steerQueuedChatMessage(id), onGoalCommand: (command) => void state.handleSendChat(command), - onQuestionSubmit: (actionToken, answers, onRejected) => - void state.handleSendChat( - `/codex answer ${actionToken} answers:${encodeURIComponent(JSON.stringify(answers))}`, - { onLocalCommandSendRejected: onRejected }, - ), onSideQuestion: (command, displayQuestion, onSendRejected) => void state.handleSendChat(command, { ...(displayQuestion ? { sideQuestionDisplayText: displayQuestion } : {}), diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index d3f38317d67c..a0b2daeab82b 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -157,7 +157,6 @@ import { type CompactionStatus, type FallbackStatus, type PlanStatus, - type QuestionStatus, type ToolStreamEntry, } from "./tool-stream.ts"; import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; @@ -238,7 +237,6 @@ export type ChatPageHost = ChatHost & compactionStatus: CompactionStatus | null; fallbackStatus: FallbackStatus | null; planStatus: PlanStatus | null; - questionStatus: QuestionStatus | null; chatRunStatus: ChatProps["runStatus"]; chatNewMessagesBelow: boolean; chatMetadataRequestVersion: number; @@ -1261,7 +1259,6 @@ export function createPageState( compactionStatus: null, fallbackStatus: null, planStatus: null, - questionStatus: null, chatAvatarUrl: null, chatAvatarStatus: null, chatAvatarReason: null, diff --git a/ui/src/pages/chat/chat-thread.question.test.ts b/ui/src/pages/chat/chat-thread.question.test.ts index 26a023e60355..bd2b7fa70818 100644 --- a/ui/src/pages/chat/chat-thread.question.test.ts +++ b/ui/src/pages/chat/chat-thread.question.test.ts @@ -1,7 +1,11 @@ -// Control UI tests cover question-card placement in live and terminal chat runs. +/* @vitest-environment jsdom */ + +// Control UI tests cover composer-only pending questions and terminal transcript summaries. +import { render } from "lit"; import { afterEach, describe, expect, it } from "vitest"; import type { QuestionPrompt } from "../../app/question-prompt.ts"; import { buildCachedChatItems, coalesceStreamRuns, resetChatThreadState } from "./chat-thread.ts"; +import { renderChatQuestionSummary } from "./components/chat-question-card.ts"; function prompt(status: QuestionPrompt["status"]): QuestionPrompt { return { @@ -52,13 +56,12 @@ function items(question: QuestionPrompt, runActive: boolean) { afterEach(() => resetChatThreadState()); describe("question chat items", () => { - it("groups a pending question with the active run and plan", () => { + it("keeps a pending question out of the message stream", () => { const result = coalesceStreamRuns(items(prompt("pending"), true)); const run = result.find((item) => item.kind === "stream-run"); expect(run?.kind).toBe("stream-run"); expect(run?.kind === "stream-run" ? run.parts.map((part) => part.kind) : []).toEqual([ - "question", "reading-indicator", "plan", ]); @@ -67,7 +70,38 @@ describe("question chat items", () => { it("keeps a terminal question as a stable transcript item", () => { const result = coalesceStreamRuns(items(prompt("expired"), false)); - expect(result).toMatchObject([{ kind: "question", questionId: "question-1", pending: false }]); + expect(result).toMatchObject([{ kind: "question", questionId: "question-1" }]); + }); + + it("renders answered and skipped prompts as compact summary lines", () => { + const answered = prompt("answered"); + answered.answers = { answers: { format: { answers: ["Compact"] } } }; + const skipped = prompt("cancelled"); + const container = document.createElement("div"); + + render(renderChatQuestionSummary(answered), container); + expect( + container.querySelector(".chat-question-summary")?.textContent?.replace(/\s+/g, " "), + ).toContain("Format: Compact"); + + render(renderChatQuestionSummary(skipped), container); + expect( + container.querySelector(".chat-question-summary")?.textContent?.replace(/\s+/g, " "), + ).toContain("Format: Skipped"); + expect(container.querySelector(".chat-question-panel")).toBeNull(); + }); + + it("keeps supplied answer labels when another client resolved the question", () => { + const answered = prompt("answered"); + answered.answeredElsewhere = true; + answered.answers = { answers: { format: { answers: ["Detailed"] } } }; + const container = document.createElement("div"); + + render(renderChatQuestionSummary(answered), container); + + expect( + container.querySelector(".chat-question-summary")?.textContent?.replace(/\s+/g, " "), + ).toContain("Format: Detailed"); }); it("omits questions belonging to another session", () => { diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index 542b131ba651..fd4a4431de12 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -1488,9 +1488,12 @@ function buildChatItems(props: BuildChatItemsProps): Array { const result: Array = []; let run: StreamRunRenderItem["parts"] = []; - // Contiguous in-flight stream, question, plan, and reading-indicator items render under one + // Contiguous in-flight stream, plan, and reading-indicator items render under one // assistant avatar; messages, groups, and dividers intentionally break the run. const flush = () => { const [first] = run; @@ -1786,12 +1787,7 @@ export function coalesceStreamRuns( } }; for (const item of items) { - if ( - item.kind === "stream" || - item.kind === "reading-indicator" || - (item.kind === "question" && item.pending) || - item.kind === "plan" - ) { + if (item.kind === "stream" || item.kind === "reading-indicator" || item.kind === "plan") { run.push(item); continue; } diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 0725f7315a95..f11fccb99c55 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -59,12 +59,7 @@ import type { RealtimeTalkConversationEntry } from "./realtime-talk-conversation import type { RealtimeTalkLevelSignal } from "./realtime-talk-level.ts"; import type { RealtimeTalkStatus } from "./realtime-talk.ts"; import type { ChatRunUiStatus } from "./run-lifecycle.ts"; -import type { - CompactionStatus, - FallbackStatus, - PlanStatus, - QuestionStatus, -} from "./tool-stream.ts"; +import type { CompactionStatus, FallbackStatus, PlanStatus } from "./tool-stream.ts"; import "../../components/resizable-divider.ts"; function isFileDrag(dataTransfer: DataTransfer | null): boolean { @@ -87,10 +82,10 @@ export type ChatProps = { compactionStatus?: CompactionStatus | null; fallbackStatus?: FallbackStatus | null; planStatus?: PlanStatus | null; - questionStatus?: QuestionStatus | null; gatewayQuestionPrompts?: readonly QuestionPrompt[]; onGatewayQuestionChange?: () => void; onGatewayQuestionSubmit?: (id: string, answers: Record) => void | Promise; + onGatewayQuestionSkip?: (id: string) => void | Promise; messages: unknown[]; historyPagination?: { loading: boolean; @@ -167,11 +162,6 @@ export type ChatProps = { onQueueRetry?: (id: string) => void; onQueueSteer?: (id: string) => void; onGoalCommand?: (command: string) => void; - onQuestionSubmit?: ( - actionToken: string, - answers: Record, - onRejected: () => void, - ) => void; onHistoryIntent?: (event: Event) => void; /** Sends a detached /btw side question (selection popup or side-chat * follow-up). `displayQuestion` overrides the pending-turn display when the @@ -311,8 +301,6 @@ export function renderChat(props: ChatProps) { onOpenSessionCheckpoints: props.onOpenSessionCheckpoints, onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded, onRequestUpdate: requestUpdate, - onQuestionChange: props.onGatewayQuestionChange, - onQuestionSubmit: props.onGatewayQuestionSubmit, onChatScroll: props.onChatScroll, onHistoryIntent: props.onHistoryIntent, onDraftChange: props.onDraftChange, @@ -345,7 +333,7 @@ export function renderChat(props: ChatProps) { compactionStatus: props.compactionStatus, fallbackStatus: props.fallbackStatus, planStatus: props.planStatus, - questionStatus: props.questionStatus, + gatewayQuestionPrompts: props.gatewayQuestionPrompts, messages: props.messages, stream: props.stream, queue: props.queue, @@ -380,7 +368,9 @@ export function renderChat(props: ChatProps) { onQueueRetry: props.onQueueRetry, onQueueSteer: props.onQueueSteer, onGoalCommand: props.onGoalCommand, - onQuestionSubmit: props.onQuestionSubmit, + onGatewayQuestionChange: props.onGatewayQuestionChange, + onGatewayQuestionSubmit: props.onGatewayQuestionSubmit, + onGatewayQuestionSkip: props.onGatewayQuestionSkip, onNewSession: props.onNewSession, onClearReply: props.onClearReply, onAttachmentsChange: props.onAttachmentsChange, diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index ff919c7d1cc3..06c9b332ca61 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -5,6 +5,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { ref } from "lit/directives/ref.js"; import type { GatewaySessionRow, SessionGoal, SessionsListResult } from "../../../api/types.ts"; import { normalizeBasePath } from "../../../app-route-paths.ts"; +import type { QuestionPrompt } from "../../../app/question-prompt.ts"; import { normalizeChatSendShortcut, type ChatSendShortcut } from "../../../app/settings.ts"; import { icons, type IconName } from "../../../components/icons.ts"; import "../../../components/tooltip.ts"; @@ -38,6 +39,7 @@ import { formatGoalUsage, goalElapsedMs, } from "../../../lib/session-goal.ts"; +import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts"; import { detectTextDirection } from "../../../lib/text-direction.ts"; import { exportChatMarkdown } from "../export.ts"; import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts"; @@ -54,7 +56,7 @@ import { renderChatAttachmentMenu, } from "./chat-attachments.ts"; import { renderChatPlanChecklist } from "./chat-plan-checklist.ts"; -import { createCodexQuestionCardProps } from "./chat-question-card.ts"; +import { createGatewayQuestionPanelProps } from "./chat-question-card.ts"; import { renderChatVoiceError, renderMicrophoneActivity, @@ -92,7 +94,7 @@ type ChatComposerProps = { compactionStatus?: CompactionStatus | null; fallbackStatus?: FallbackStatus | null; planStatus?: PlanStatus | null; - questionStatus?: import("../tool-stream.ts").QuestionStatus | null; + gatewayQuestionPrompts?: readonly QuestionPrompt[]; messages: unknown[]; stream: string | null; queue: ChatQueueItem[]; @@ -130,11 +132,9 @@ type ChatComposerProps = { onClearReply?: () => void; onAttachmentsChange?: (attachments: ChatAttachment[]) => void; onGoalCommand?: (command: string) => void; - onQuestionSubmit?: ( - actionToken: string, - answers: Record, - onRejected: () => void, - ) => void; + onGatewayQuestionChange?: () => void; + onGatewayQuestionSubmit?: (id: string, answers: Record) => void | Promise; + onGatewayQuestionSkip?: (id: string) => void | Promise; }; type PendingClearedSubmittedDraft = { @@ -161,6 +161,7 @@ type ChatComposerState = { composerInputIntentKey: string | null; pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null; goalExpandedId: string | null; + activeGatewayQuestionId: string | null; composerTextarea: HTMLTextAreaElement | null; // Stable Lit ref: an inline arrow would change identity per render and force // a layout re-measure of the textarea on every chat render, not just attach. @@ -182,6 +183,7 @@ function createChatComposerState(): ChatComposerState { composerInputIntentKey: null, pendingClearedSubmittedDraft: null, goalExpandedId: null, + activeGatewayQuestionId: null, composerTextarea: null, textareaRef: null, }; @@ -299,6 +301,7 @@ export function resetChatComposerState(paneId?: string) { } const composerTextareaResizeObservers = new WeakMap(); +const questionDockResizeObservers = new WeakMap(); function updateTextareaOverflow(el: HTMLTextAreaElement) { el.style.overflowY = el.scrollHeight > el.clientHeight ? "auto" : "hidden"; @@ -327,6 +330,29 @@ function disconnectTextareaOverflowObserver(el: HTMLTextAreaElement) { composerTextareaResizeObservers.delete(el); } +function syncQuestionDockHeight(el: HTMLElement): void { + el.closest(".chat")?.style.setProperty( + "--chat-question-dock-height", + `${el.offsetHeight}px`, + ); +} + +function observeQuestionDock(el: HTMLElement): void { + syncQuestionDockHeight(el); + if (typeof ResizeObserver !== "function" || questionDockResizeObservers.has(el)) { + return; + } + const observer = new ResizeObserver(() => syncQuestionDockHeight(el)); + observer.observe(el); + questionDockResizeObservers.set(el, observer); +} + +function disconnectQuestionDock(el: HTMLElement): void { + questionDockResizeObservers.get(el)?.disconnect(); + questionDockResizeObservers.delete(el); + el.closest(".chat")?.style.removeProperty("--chat-question-dock-height"); +} + function scheduleTextareaHeightAdjustment(el: HTMLTextAreaElement) { // Lit invokes ref callbacks before the textarea is connected and before its // controlled value is committed, so measure once the render has settled. @@ -1892,6 +1918,7 @@ export function renderChatComposer(props: ChatComposerProps) { const draftKey = composerDraftKey(props); const actionDraft = state.composingDraft?.key === draftKey ? state.composingDraft.value : visibleDraft; + let questionDock: HTMLElement | null = null; state.textareaRef ??= (element?: Element) => { const nextTextarea = element instanceof HTMLTextAreaElement ? element : null; const prevTextarea = state.composerTextarea; @@ -1946,6 +1973,52 @@ export function renderChatComposer(props: ChatComposerProps) { : t("chat.composer.runInterrupted"); const requestUpdate = props.onRequestUpdate ?? (() => {}); const sendShortcut = normalizeChatSendShortcut(props.sendShortcut); + const gatewayQuestionPrompts = + props.gatewayQuestionPrompts?.filter( + (prompt) => + prompt.status === "pending" && + prompt.sessionKey !== undefined && + areUiSessionKeysEquivalent(prompt.sessionKey, props.sessionKey), + ) ?? []; + let gatewayQuestionIndex = gatewayQuestionPrompts.findIndex( + (prompt) => prompt.id === state.activeGatewayQuestionId, + ); + if (gatewayQuestionIndex < 0 && gatewayQuestionPrompts.length > 0) { + gatewayQuestionIndex = 0; + state.activeGatewayQuestionId = gatewayQuestionPrompts[0]?.id ?? null; + } + const gatewayQuestionPrompt = gatewayQuestionPrompts[gatewayQuestionIndex]; + const selectGatewayQuestion = (index: number) => { + const prompt = gatewayQuestionPrompts[index]; + if (!prompt) { + return; + } + state.activeGatewayQuestionId = prompt.id; + requestUpdate(); + }; + const questionPanelProps = gatewayQuestionPrompt + ? createGatewayQuestionPanelProps(gatewayQuestionPrompt, { + nowMs: Date.now(), + onChange: props.onGatewayQuestionChange, + onSubmit: props.onGatewayQuestionSubmit + ? (answers) => props.onGatewayQuestionSubmit?.(gatewayQuestionPrompt.id, answers) + : undefined, + onSkip: props.onGatewayQuestionSkip + ? () => props.onGatewayQuestionSkip?.(gatewayQuestionPrompt.id) + : undefined, + requestPosition: + gatewayQuestionPrompts.length > 1 + ? { current: gatewayQuestionIndex + 1, total: gatewayQuestionPrompts.length } + : undefined, + onPreviousRequest: () => + selectGatewayQuestion( + (gatewayQuestionIndex - 1 + gatewayQuestionPrompts.length) % + gatewayQuestionPrompts.length, + ), + onNextRequest: () => + selectGatewayQuestion((gatewayQuestionIndex + 1) % gatewayQuestionPrompts.length), + }) + : null; const placeholder = !canCompose && props.disabledReason @@ -2221,6 +2294,27 @@ export function renderChatComposer(props: ChatComposerProps) { onQueueRemove: props.onQueueRemove, })}
+ ${questionPanelProps + ? html` +
{ + const nextDock = element instanceof HTMLElement ? element : null; + if (questionDock && questionDock !== nextDock) { + disconnectQuestionDock(questionDock); + } + questionDock = nextDock; + if (questionDock) { + observeQuestionDock(questionDock); + } + })} + > + +
+ ` + : nothing}
focusComposerFromChrome(event, canCompose)} @@ -2253,19 +2347,6 @@ export function renderChatComposer(props: ChatComposerProps) { ` : nothing}
- ${props.questionStatus - ? html` - props.onQuestionSubmit?.( - props.questionStatus!.actionToken, - answers, - onRejected, - ), - })} - >` - : nothing} ${renderChatPlanChecklist(props.planStatus, { active: showAbortableUi, variant: "bar", diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index e23776e987e5..9446cf4eb463 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -58,7 +58,7 @@ import { getSafeLocalStorage } from "../../../local-storage.ts"; import { renderChatAvatar } from "../chat-avatar.ts"; import type { PlanStatus } from "../tool-stream.ts"; import { renderChatPlanChecklist } from "./chat-plan-checklist.ts"; -import { renderChatQuestionCard } from "./chat-question-card.ts"; +import { renderChatQuestionSummary } from "./chat-question-card.ts"; import type { SidebarContent } from "./chat-sidebar.ts"; import { isRunningToolCard, @@ -639,8 +639,6 @@ type StreamGroupOptions = { planStatus?: PlanStatus | null; planActive?: boolean; questionPrompts?: ReadonlyMap; - onQuestionChange?: () => void; - onQuestionSubmit?: (id: string, answers: Record) => void | Promise; }; function renderQuestionStreamPart( @@ -648,13 +646,7 @@ function renderQuestionStreamPart( opts: StreamGroupOptions, ) { const prompt = opts.questionPrompts?.get(part.questionId); - return prompt - ? renderChatQuestionCard(prompt, { - nowMs: Date.now(), - onChange: opts.onQuestionChange ?? (() => {}), - onSubmit: (answers) => opts.onQuestionSubmit?.(prompt.id, answers), - }) - : nothing; + return prompt ? renderChatQuestionSummary(prompt) : nothing; } // One assistant group per contiguous run of streaming items: a reply that diff --git a/ui/src/pages/chat/components/chat-question-card.test.ts b/ui/src/pages/chat/components/chat-question-card.test.ts index 666c31cee658..76eab397e91a 100644 --- a/ui/src/pages/chat/components/chat-question-card.test.ts +++ b/ui/src/pages/chat/components/chat-question-card.test.ts @@ -3,30 +3,12 @@ import { html, render } from "lit"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { QuestionPrompt } from "../../../app/question-prompt.ts"; -import type { QuestionStatus } from "../tool-stream.ts"; -import { createCodexQuestionCardProps, renderChatQuestionCard } from "./chat-question-card.ts"; +import { createGatewayQuestionPanelProps } from "./chat-question-card.ts"; -type ChatQuestionCardElement = HTMLElement & { +type ChatQuestionPanelElement = HTMLElement & { updateComplete: Promise; }; -function codexStatus(overrides: Partial = {}): QuestionStatus { - return { - itemId: "item-1", - actionToken: "test-action-token", - questions: [ - { - id: "mode", - header: "Mode", - question: "Pick one", - isOther: false, - options: [{ label: "Fast" }, { label: " Deep ", description: "More reasoning" }], - }, - ], - ...overrides, - }; -} - function gatewayPrompt(overrides: Partial = {}): QuestionPrompt { return { id: "question-1", @@ -57,13 +39,13 @@ function gatewayPrompt(overrides: Partial = {}): QuestionPrompt }; } -async function cardIn(container: HTMLElement): Promise { - const card = container.querySelector("openclaw-chat-question") as ChatQuestionCardElement; - await card.updateComplete; - return card; +async function panelIn(container: HTMLElement): Promise { + const panel = container.querySelector("openclaw-chat-question-panel") as ChatQuestionPanelElement; + await panel.updateComplete; + return panel; } -describe("shared question card", () => { +describe("shared question panel", () => { let container: HTMLDivElement; beforeEach(() => { @@ -75,243 +57,240 @@ describe("shared question card", () => { container.remove(); }); - describe("Codex adapter", () => { - function draw( - status: QuestionStatus, - onSubmit: (answers: Record, onRejected: () => void) => void, - ) { + function drawGateway( + prompt: QuestionPrompt, + callbacks: { + onSubmit?: (answers: Record) => void | Promise; + onSkip?: () => void | Promise; + } = {}, + ) { + const redraw = () => { render( - html``, - container, - ); - } - - it("submits a selected native option through the string-answer seam", async () => { - const onSubmit = vi.fn(); - draw(codexStatus(), onSubmit); - const card = await cardIn(container); - const options = container.querySelectorAll('input[type="radio"]'); - - options[1]!.click(); - await card.updateComplete; - container.querySelector(".chat-question__submit")!.click(); - - expect(onSubmit).toHaveBeenCalledWith({ mode: " Deep " }, expect.any(Function)); - await card.updateComplete; - expect(container.querySelector(".chat-question__submit")?.disabled).toBe( - true, - ); - }); - - it("keeps the main submit label when the composer is disconnected", async () => { - render( - html``, - container, - ); - await cardIn(container); - - const submit = container.querySelector(".chat-question__submit"); - expect(submit?.textContent?.trim()).toBe("Submit answer"); - expect(submit?.disabled).toBe(true); - }); - - it("re-enables submission when the scoped command is rejected", async () => { - let reject: (() => void) | undefined; - draw(codexStatus(), (_answers, onRejected) => { - reject = onRejected; - }); - const card = await cardIn(container); - container.querySelector('input[type="radio"]')!.click(); - await card.updateComplete; - const submit = container.querySelector(".chat-question__submit")!; - submit.click(); - await card.updateComplete; - expect(submit.disabled).toBe(true); - - reject?.(); - await Promise.resolve(); - await card.updateComplete; - expect(submit.disabled).toBe(false); - }); - - it("clears free-form text when the request key changes", async () => { - const status = codexStatus({ - itemId: "reused-item", - questions: [ - { - id: "other", - header: "Alternative", - question: "Type another answer", - isOther: true, - options: [], - }, - ], - }); - draw(status, vi.fn()); - const card = await cardIn(container); - const input = container.querySelector(".chat-question__other")!; - input.value = "stale answer"; - input.dispatchEvent(new InputEvent("input", { bubbles: true })); - await card.updateComplete; - - // A new itemId changes the request key exactly like a new action token. - draw({ ...status, itemId: "item-second-request" }, vi.fn()); - await card.updateComplete; - expect(container.querySelector(".chat-question__other")?.value).toBe(""); - }); - - it("preserves free-form text that begins with an option label", async () => { - draw( - codexStatus({ - questions: [ - { - id: "reason", - header: "Decision", - question: "Continue?", - isOther: true, - options: [{ label: "No" }], - }, - ], - }), - vi.fn(), - ); - const card = await cardIn(container); - const input = container.querySelector(".chat-question__other")!; - input.value = "No, because the proof failed"; - input.dispatchEvent(new InputEvent("input", { bubbles: true })); - await card.updateComplete; - - expect(input.value).toBe("No, because the proof failed"); - }); - }); - - describe("gateway adapter", () => { - async function draw( - prompt: QuestionPrompt, - onSubmit: (answers: Record) => void | Promise = vi.fn(), - ) { - const redraw = () => { - render( - renderChatQuestionCard(prompt, { + html``, + container, + ); + }; + redraw(); + } - it("submits multiselect options and free text as arrays", async () => { - const prompt = gatewayPrompt({ - questions: [ - { - id: "extras", - header: "Extras", - question: "Which extras should I include?", - options: [{ label: "Tests" }, { label: "Docs" }], - multiSelect: true, - isOther: true, - }, - { - id: "target", - header: "Target", - question: "Where should I send it?", - options: [{ label: "Chat" }, { label: "File" }], - isOther: true, - }, - ], - }); - const onSubmit = await draw(prompt); - const card = await cardIn(container); - const checkboxes = container.querySelectorAll('input[type="checkbox"]'); - checkboxes[0]?.click(); - checkboxes[1]?.click(); - const targetInput = container.querySelectorAll(".chat-question__other")[1]!; - targetInput.value = "Issue comment"; - targetInput.dispatchEvent(new InputEvent("input", { bubbles: true })); - await card.updateComplete; - - container.querySelector(".chat-question__submit")?.click(); - - expect(onSubmit).toHaveBeenCalledWith({ - extras: ["Tests", "Docs"], - target: ["Issue comment"], - }); + it("steps from single-select to multi-select and preserves array answers", async () => { + const prompt = gatewayPrompt({ + questions: [ + { + id: "target", + header: "Target", + question: "Where should I send it?", + options: [{ label: "Chat" }, { label: "File" }], + isOther: true, + }, + { + id: "extras", + header: "Extras", + question: "Which extras should I include?", + options: [{ label: "Tests" }, { label: "Docs" }], + multiSelect: true, + isOther: true, + }, + ], }); + const onSubmit = vi.fn(); + drawGateway(prompt, { onSubmit }); + const panel = await panelIn(container); - it("renders countdown and answered-elsewhere state", async () => { - const prompt = gatewayPrompt(); - await draw(prompt); - expect(container.querySelector(".chat-question__countdown")?.textContent).toBe("1:00"); + expect(container.querySelector(".chat-question-panel__progress")?.textContent).toBe("1/2"); + container.querySelector('[role="radio"]')?.click(); + await panel.updateComplete; - prompt.status = "answered"; - prompt.answeredElsewhere = true; - prompt.answers = { answers: { format: { answers: ["Detailed"] } } }; - await draw(prompt); + expect(container.querySelector(".chat-question-panel__prompt")?.textContent).toBe( + "Which extras should I include?", + ); + expect(container.querySelector(".chat-question-panel__progress")?.textContent).toBe("2/2"); + container.querySelector(".chat-question-panel__back")?.click(); + await panel.updateComplete; + expect(container.querySelector(".chat-question-panel__prompt")?.textContent).toBe( + "Where should I send it?", + ); + container.querySelector('[role="radio"]')?.click(); + await panel.updateComplete; + container.querySelectorAll('[role="checkbox"]')[0]?.click(); + container.querySelectorAll('[role="checkbox"]')[1]?.click(); + const other = container.querySelector(".chat-question-panel__other")!; + other.value = "Metrics"; + other.dispatchEvent(new InputEvent("input", { bubbles: true })); + await panel.updateComplete; + container.querySelector(".chat-question-panel__advance")?.click(); - expect(container.querySelector(".chat-question__status")?.textContent).toBe( - "Answered elsewhere", - ); - expect(container.querySelectorAll('input[type="radio"]')[1]?.checked).toBe( - true, - ); - expect(container.querySelector('input[type="radio"]')?.disabled).toBe(true); - }); - - it.each([ - ["expired", "Expired"], - ["cancelled", "Cancelled"], - ] as const)("renders %s terminal state", async (status, label) => { - await draw(gatewayPrompt({ status })); - - expect(container.querySelector(".chat-question__status")?.textContent).toBe(label); - expect(container.querySelector(".chat-question__submit")).toBeNull(); - }); - - it("shows resolve errors while leaving another attempt enabled", async () => { - const prompt = gatewayPrompt({ error: "gateway unavailable" }); - await draw(prompt); - - expect(container.querySelector(".chat-question__error")?.textContent).toContain( - "gateway unavailable", - ); - expect(container.querySelector(".chat-question__submit")?.disabled).toBe( - true, - ); - container.querySelector('input[type="radio"]')?.click(); - await cardIn(container); - expect(container.querySelector(".chat-question__submit")?.disabled).toBe( - false, - ); - }); - - it("clears the private submitted latch after a handled gateway rejection", async () => { - const prompt = gatewayPrompt(); - await draw(prompt, async () => { - prompt.error = "gateway unavailable"; - }); - const card = await cardIn(container); - container.querySelector('input[type="radio"]')?.click(); - await card.updateComplete; - - container.querySelector(".chat-question__submit")?.click(); - - await vi.waitFor(() => - expect(container.querySelector(".chat-question__submit")?.disabled).toBe( - false, - ), - ); + expect(onSubmit).toHaveBeenCalledWith({ + target: ["Chat"], + extras: ["Tests", "Docs", "Metrics"], }); }); + + it("supports numeric selection and Enter submission while focused", async () => { + const onSubmit = vi.fn(); + drawGateway(gatewayPrompt(), { onSubmit }); + const panel = await panelIn(container); + const group = container.querySelector(".chat-question-panel")!; + + group.dispatchEvent(new KeyboardEvent("keydown", { key: "2", bubbles: true })); + await panel.updateComplete; + expect( + container.querySelectorAll('[role="radio"]')[1]?.getAttribute("aria-checked"), + ).toBe("true"); + + group.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(onSubmit).toHaveBeenCalledWith({ format: ["Detailed"] }); + }); + + it("leaves modified numeric shortcuts to the browser", async () => { + const onSubmit = vi.fn(); + drawGateway(gatewayPrompt(), { onSubmit }); + const panel = await panelIn(container); + const group = container.querySelector(".chat-question-panel")!; + + group.dispatchEvent(new KeyboardEvent("keydown", { key: "2", ctrlKey: true, bubbles: true })); + await panel.updateComplete; + + expect(container.querySelector('[aria-checked="true"]')).toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("uses roving radio focus and arrow-key selection", async () => { + drawGateway( + gatewayPrompt({ + questions: [ + ...gatewayPrompt().questions, + { + id: "confirm", + header: "Confirm", + question: "Ready to continue?", + options: [{ label: "Ready" }], + isOther: false, + }, + ], + }), + ); + const panel = await panelIn(container); + const radios = container.querySelectorAll('[role="radio"]'); + + expect([...radios].map((radio) => radio.tabIndex)).toEqual([0, -1]); + radios[0]?.focus(); + radios[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + await panel.updateComplete; + + const updated = container.querySelectorAll('[role="radio"]'); + expect([...updated].map((radio) => radio.tabIndex)).toEqual([-1, 0]); + expect(updated[1]?.getAttribute("aria-checked")).toBe("true"); + expect(document.activeElement).toBe(updated[1]); + expect(container.querySelector(".chat-question-panel__prompt")?.textContent).toBe( + "Which format should I use?", + ); + }); + + it("uses Enter in Other to advance and submit free text", async () => { + const onSubmit = vi.fn(); + drawGateway(gatewayPrompt(), { onSubmit }); + const panel = await panelIn(container); + const other = container.querySelector(".chat-question-panel__other")!; + + other.value = "Markdown table"; + other.dispatchEvent(new InputEvent("input", { bubbles: true })); + other.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await panel.updateComplete; + + expect(onSubmit).toHaveBeenCalledWith({ format: ["Markdown table"] }); + }); + + it("uses Enter in empty Other to submit an already-selected option", async () => { + const onSubmit = vi.fn(); + drawGateway(gatewayPrompt(), { onSubmit }); + const panel = await panelIn(container); + + container.querySelector('[role="radio"]')?.click(); + await panel.updateComplete; + const other = container.querySelector(".chat-question-panel__other")!; + other.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(onSubmit).toHaveBeenCalledWith({ format: ["Compact"] }); + }); + + it("collapses without answering and exposes gateway cancellation through Skip", async () => { + const onSkip = vi.fn(); + drawGateway(gatewayPrompt(), { onSkip }); + const panel = await panelIn(container); + + container.querySelector(".chat-question-panel__collapse")?.click(); + await panel.updateComplete; + expect(container.querySelector(".chat-question-panel--collapsed")?.textContent).toContain( + "Format", + ); + expect(onSkip).not.toHaveBeenCalled(); + + container.querySelector(".chat-question-panel__collapsed-button")?.click(); + await panel.updateComplete; + container.querySelector(".chat-question-panel__skip")?.click(); + expect(onSkip).toHaveBeenCalledOnce(); + }); + + it("disables actions whose gateway callbacks are unavailable", async () => { + render( + html``, + container, + ); + await panelIn(container); + + expect( + container.querySelector(".chat-question-panel__advance")?.disabled, + ).toBe(true); + expect(container.querySelector(".chat-question-panel__skip")).toBeNull(); + }); + + it("retains answers with submit-only wiring", async () => { + const onSubmit = vi.fn(); + render( + html``, + container, + ); + const panel = await panelIn(container); + + container.querySelector('[role="radio"]')?.click(); + await panel.updateComplete; + container.querySelector(".chat-question-panel__advance")?.click(); + + expect(onSubmit).toHaveBeenCalledWith({ format: ["Compact"] }); + }); + + it("keeps Skip available with skip-only wiring", async () => { + const onSkip = vi.fn(); + render( + html``, + container, + ); + await panelIn(container); + + expect( + container.querySelector(".chat-question-panel__advance")?.disabled, + ).toBe(true); + container.querySelector(".chat-question-panel__skip")?.click(); + expect(onSkip).toHaveBeenCalledOnce(); + }); }); diff --git a/ui/src/pages/chat/components/chat-question-card.ts b/ui/src/pages/chat/components/chat-question-card.ts index 20a2a773e819..943320c07269 100644 --- a/ui/src/pages/chat/components/chat-question-card.ts +++ b/ui/src/pages/chat/components/chat-question-card.ts @@ -1,11 +1,11 @@ -// Control UI chat module renders provider-neutral question cards. +// Control UI chat module renders the shared docked question panel and terminal summaries. import { LitElement, html, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { QuestionPrompt } from "../../../app/question-prompt.ts"; +import { icons } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; -import type { QuestionStatus } from "../tool-stream.ts"; -type QuestionCardQuestion = { +type QuestionPanelQuestion = { id: string; header: string; question: string; @@ -14,36 +14,36 @@ type QuestionCardQuestion = { isOther?: boolean; }; -type QuestionCardTerminalState = "answered" | "answered-elsewhere" | "expired" | "cancelled"; - -type QuestionCardViewModel = { +type QuestionPanelViewModel = { requestKey: string; title: string; - questions: QuestionCardQuestion[]; - terminalState?: QuestionCardTerminalState; + questions: QuestionPanelQuestion[]; disabled: boolean; submitting?: boolean; countdown?: string; answersById?: Record; error?: string | null; + requestPosition?: { current: number; total: number }; }; -type QuestionCardProps = { - model: QuestionCardViewModel; - onSubmit: (answersById: Record) => void | Promise; +type QuestionPanelProps = { + model: QuestionPanelViewModel; + onSubmit?: (answersById: Record) => void | Promise; + onSkip?: () => void | Promise; onAnswersChange?: (answersById: Record) => void; onDismissError?: () => void; + onPreviousRequest?: () => void; + onNextRequest?: () => void; }; -type CodexQuestionCardOptions = { - disabled: boolean; - onSubmit: (answers: Record, onRejected: () => void) => void; -}; - -type GatewayQuestionCardOptions = { +type GatewayQuestionPanelOptions = { nowMs: number; - onChange: () => void; - onSubmit: (answers: Record) => void | Promise; + onChange?: () => void; + onSubmit?: (answers: Record) => void | Promise; + onSkip?: () => void | Promise; + requestPosition?: { current: number; total: number }; + onPreviousRequest?: () => void; + onNextRequest?: () => void; }; function formatRemaining(expiresAtMs: number, nowMs: number): string { @@ -52,22 +52,7 @@ function formatRemaining(expiresAtMs: number, nowMs: number): string { return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; } -function terminalStateForPrompt(prompt: QuestionPrompt): QuestionCardTerminalState | undefined { - if (prompt.status === "answered") { - return prompt.answeredElsewhere ? "answered-elsewhere" : "answered"; - } - return prompt.status === "pending" ? undefined : prompt.status; -} - function promptDraftAnswers(prompt: QuestionPrompt): Record { - if (prompt.status === "answered") { - return Object.fromEntries( - prompt.questions.map((question) => [ - question.id, - prompt.answers?.answers[question.id]?.answers ?? [], - ]), - ); - } return Object.fromEntries( prompt.questions.map((question) => { const draft = prompt.drafts.get(question.id); @@ -90,62 +75,90 @@ function updatePromptDrafts(prompt: QuestionPrompt, answersById: Record - new Promise((_resolve, reject) => { - const answers = Object.fromEntries( - Object.entries(answersById).map(([id, values]) => [id, values[0] ?? ""]), - ); - options.onSubmit(answers, () => reject(new Error("question submission rejected"))); - }), - }; -} - -export function renderChatQuestionCard( +export function createGatewayQuestionPanelProps( prompt: QuestionPrompt, - options: GatewayQuestionCardOptions, -) { - const pending = prompt.status === "pending"; - const props: QuestionCardProps = { + options: GatewayQuestionPanelOptions, +): QuestionPanelProps { + return { model: { requestKey: prompt.id, title: t("chat.questions.eyebrow"), questions: prompt.questions, - terminalState: terminalStateForPrompt(prompt), - disabled: !pending || prompt.submitting, + disabled: prompt.status !== "pending" || prompt.submitting, submitting: prompt.submitting, - countdown: pending ? formatRemaining(prompt.expiresAtMs, options.nowMs) : undefined, + countdown: + prompt.status === "pending" + ? formatRemaining(prompt.expiresAtMs, options.nowMs) + : undefined, answersById: promptDraftAnswers(prompt), error: prompt.error, + requestPosition: options.requestPosition, }, onAnswersChange: (answersById) => { updatePromptDrafts(prompt, answersById); - options.onChange(); + options.onChange?.(); }, - onSubmit: async (answersById) => { - await options.onSubmit(answersById); - if (prompt.status === "pending" && prompt.error) { - throw new Error(prompt.error); - } - }, - onDismissError: prompt.error - ? () => { - prompt.error = null; - options.onChange(); + onSubmit: options.onSubmit + ? async (answersById) => { + await options.onSubmit?.(answersById); + if (prompt.status === "pending" && prompt.error) { + throw new Error(prompt.error); + } } : undefined, + onSkip: options.onSkip + ? async () => { + await options.onSkip?.(); + if (prompt.status === "pending" && prompt.error) { + throw new Error(prompt.error); + } + } + : undefined, + onDismissError: + prompt.error && options.onChange + ? () => { + prompt.error = null; + options.onChange?.(); + } + : undefined, + onPreviousRequest: options.onPreviousRequest, + onNextRequest: options.onNextRequest, }; - return html``; +} + +function terminalAnswer(prompt: QuestionPrompt, question: QuestionPanelQuestion): string { + if (prompt.status === "cancelled") { + return t("chat.questions.skipped"); + } + if (prompt.status === "expired") { + return t("chat.questions.expired"); + } + const answer = prompt.answers?.answers[question.id]?.answers.join(", "); + if (answer) { + return answer; + } + if (prompt.answeredElsewhere) { + return t("chat.questions.answeredElsewhere"); + } + return t("chat.questions.answered"); +} + +export function renderChatQuestionSummary(prompt: QuestionPrompt) { + if (prompt.status === "pending") { + return nothing; + } + return html` +
+ ${prompt.questions.map( + (question) => html` +
+ ${question.header}: + ${terminalAnswer(prompt, question)} +
+ `, + )} +
+ `; } function answersSignature(answersById: Record): string { @@ -156,15 +169,17 @@ function answersSignature(answersById: Record): string { ); } -class ChatQuestionCard extends LitElement { +class ChatQuestionPanel extends LitElement { override createRenderRoot() { return this; } - @property({ attribute: false }) props?: QuestionCardProps; + @property({ attribute: false }) props?: QuestionPanelProps; @state() private selectedById = new Map(); @state() private freeTextById = new Map(); - @state() private submitted = false; + @state() private currentQuestionIndex = 0; + @state() private collapsed = false; + @state() private pendingAction: "submit" | "skip" | null = null; private requestKey: string | null = null; private syncedAnswersSignature: string | null = null; @@ -175,7 +190,9 @@ class ChatQuestionCard extends LitElement { this.requestKey = nextRequestKey; this.selectedById = new Map(); this.freeTextById = new Map(); - this.submitted = false; + this.currentQuestionIndex = 0; + this.collapsed = false; + this.pendingAction = null; this.syncedAnswersSignature = null; } if (!model?.answersById) { @@ -204,29 +221,37 @@ class ChatQuestionCard extends LitElement { this.freeTextById = freeTextById; } - private answerValues(question: QuestionCardQuestion): string[] { + private answerValues(question: QuestionPanelQuestion): string[] { const selected = this.selectedById.get(question.id) ?? []; const freeText = this.freeTextById.get(question.id)?.trim(); return [...selected, ...(freeText ? [freeText] : [])]; } - private buildAnswers(model: QuestionCardViewModel): Record { + private buildAnswers(model: QuestionPanelViewModel): Record { return Object.fromEntries( model.questions.map((question) => [question.id, this.answerValues(question)]), ); } - private answersChanged(model: QuestionCardViewModel): void { + private answersChanged(model: QuestionPanelViewModel): void { const answersById = this.buildAnswers(model); + model.answersById = answersById; this.syncedAnswersSignature = answersSignature(answersById); this.props?.onAnswersChange?.(answersById); } + private focusPanel(): void { + void this.updateComplete.then(() => + this.querySelector(".chat-question-panel")?.focus({ preventScroll: true }), + ); + } + private toggleOption( - model: QuestionCardViewModel, - question: QuestionCardQuestion, + model: QuestionPanelViewModel, + question: QuestionPanelQuestion, label: string, - ) { + advance = true, + ): void { const selectedById = new Map(this.selectedById); const current = selectedById.get(question.id) ?? []; selectedById.set( @@ -235,7 +260,8 @@ class ChatQuestionCard extends LitElement { ? current.includes(label) ? current.filter((value) => value !== label) : [...current, label] - : [label], + : // Single-select re-click keeps the choice: radios never deselect. + [label], ); this.selectedById = selectedById; if (!question.multiSelect) { @@ -244,9 +270,21 @@ class ChatQuestionCard extends LitElement { this.freeTextById = freeTextById; } this.answersChanged(model); + if ( + advance && + !question.multiSelect && + this.currentQuestionIndex < model.questions.length - 1 + ) { + this.currentQuestionIndex += 1; + this.focusPanel(); + } } - private setFreeText(model: QuestionCardViewModel, question: QuestionCardQuestion, value: string) { + private setFreeText( + model: QuestionPanelViewModel, + question: QuestionPanelQuestion, + value: string, + ): void { this.freeTextById = new Map(this.freeTextById).set(question.id, value); if (!question.multiSelect && value.trim()) { this.selectedById = new Map(this.selectedById).set(question.id, []); @@ -254,107 +292,289 @@ class ChatQuestionCard extends LitElement { this.answersChanged(model); } - private async submit(model: QuestionCardViewModel): Promise { - if (!model.questions.every((question) => this.answerValues(question).length > 0)) { + private async submit(model: QuestionPanelViewModel): Promise { + if ( + !this.props?.onSubmit || + !model.questions.every((question) => this.answerValues(question).length > 0) + ) { return; } const requestKey = model.requestKey; - this.submitted = true; + this.pendingAction = "submit"; try { - await this.props?.onSubmit(this.buildAnswers(model)); + await this.props.onSubmit(this.buildAnswers(model)); } catch { if (this.requestKey === requestKey) { - this.submitted = false; + this.pendingAction = null; } } } + private async skip(model: QuestionPanelViewModel): Promise { + if (!this.props?.onSkip) { + return; + } + const requestKey = model.requestKey; + this.pendingAction = "skip"; + try { + await this.props.onSkip(); + } catch { + if (this.requestKey === requestKey) { + this.pendingAction = null; + } + } + } + + private advanceOrSubmit(model: QuestionPanelViewModel, question: QuestionPanelQuestion): void { + if (this.answerValues(question).length === 0) { + return; + } + if (this.currentQuestionIndex < model.questions.length - 1) { + this.currentQuestionIndex += 1; + this.focusPanel(); + return; + } + void this.submit(model); + } + + private goBack(): void { + if (this.currentQuestionIndex === 0) { + return; + } + this.currentQuestionIndex -= 1; + this.focusPanel(); + } + + private handleKeyDown( + event: KeyboardEvent, + model: QuestionPanelViewModel, + question: QuestionPanelQuestion, + disabled: boolean, + ): void { + if (disabled || event.isComposing || event.keyCode === 229) { + return; + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + if (event.target instanceof HTMLInputElement) { + if (event.key === "Enter" && this.answerValues(question).length > 0) { + event.preventDefault(); + this.advanceOrSubmit(model, question); + } + return; + } + if ( + event.target instanceof HTMLButtonElement && + event.target.getAttribute("role") === "radio" && + ["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "End", "Home"].includes(event.key) + ) { + event.preventDefault(); + const currentIndex = Number(event.target.dataset.optionIndex ?? "0"); + const lastIndex = question.options.length - 1; + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? lastIndex + : event.key === "ArrowLeft" || event.key === "ArrowUp" + ? (currentIndex - 1 + question.options.length) % question.options.length + : (currentIndex + 1) % question.options.length; + const nextOption = question.options[nextIndex]; + if (!nextOption) { + return; + } + const questionIndex = this.currentQuestionIndex; + // Arrow navigation follows radio-group focus without leaving the step. + // Explicit activation and numeric shortcuts keep the product's auto-advance behavior. + this.toggleOption(model, question, nextOption.label, false); + if (this.currentQuestionIndex === questionIndex) { + void this.updateComplete.then(() => + this.querySelector( + `.chat-question-panel__option[data-option-index="${nextIndex}"]`, + )?.focus({ preventScroll: true }), + ); + } + return; + } + const optionIndex = Number(event.key) - 1; + if (optionIndex >= 0 && optionIndex < 4 && question.options[optionIndex]) { + event.preventDefault(); + this.toggleOption(model, question, question.options[optionIndex].label); + return; + } + if ( + event.key === "Enter" && + !(event.target instanceof HTMLButtonElement) && + this.answerValues(question).length > 0 + ) { + event.preventDefault(); + this.advanceOrSubmit(model, question); + } + } + override render() { const props = this.props; if (!props) { return nothing; } const { model } = props; - const disabled = model.disabled || this.submitted || Boolean(model.terminalState); - const complete = model.questions.every((question) => this.answerValues(question).length > 0); + const question = model.questions[this.currentQuestionIndex]; + if (!question) { + return nothing; + } + const disabled = model.disabled || this.pendingAction !== null; + const isLast = this.currentQuestionIndex === model.questions.length - 1; + const canAdvance = this.answerValues(question).length > 0; + const progress = `${this.currentQuestionIndex + 1}/${model.questions.length}`; + const requestProgress = model.requestPosition + ? `${model.requestPosition.current}/${model.requestPosition.total}` + : null; + + if (this.collapsed) { + return html` +
+ + ${requestProgress + ? html`
+ + ${requestProgress} + +
` + : nothing} +
+ `; + } + return html` -
-
-
${model.title}
+
this.handleKeyDown(event, model, question, disabled)} + > +
+
${model.title}
+ ${requestProgress + ? html`
+ + ${requestProgress} + +
` + : nothing} ${model.countdown - ? html`${model.countdown}` : nothing} +
- ${model.questions.map( - (question) => html` -
- ${question.header} -
${question.question}
- ${question.options.map((option) => { - const selected = (this.selectedById.get(question.id) ?? []).includes(option.label); - return html` - - `; - })} - ${question.isOther || question.options.length === 0 - ? html` - - this.setFreeText(model, question, (event.target as HTMLInputElement).value)} - @keydown=${(event: KeyboardEvent) => { - if ( - event.key === "Enter" && - !event.isComposing && - event.keyCode !== 229 && - complete && - !disabled - ) { - event.preventDefault(); - void this.submit(model); - } - }} - /> - ` - : nothing} -
- `, - )} -
`; } } -if (!customElements.get("openclaw-chat-question")) { - customElements.define("openclaw-chat-question", ChatQuestionCard); +if (!customElements.get("openclaw-chat-question-panel")) { + customElements.define("openclaw-chat-question-panel", ChatQuestionPanel); } diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 8086b78fe8e6..39d326da5b3e 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -134,8 +134,6 @@ type ChatThreadProps = { onOpenSessionCheckpoints?: () => void | Promise; onAssistantAttachmentLoaded?: () => void; onRequestUpdate?: () => void; - onQuestionChange?: () => void; - onQuestionSubmit?: (id: string, answers: Record) => void | Promise; onChatScroll?: (event: Event) => void; onHistoryIntent?: (event: Event) => void; onDraftChange: (next: string) => void; @@ -1078,8 +1076,6 @@ function renderChatThreadContents( if (item.kind === "stream-run") { return renderStreamGroup(item.parts, { questionPrompts, - onQuestionChange: props.onQuestionChange, - onQuestionSubmit: props.onQuestionSubmit, planStatus: props.planStatus, planActive: Boolean(props.runActive), onOpenSidebar: props.onOpenSidebar, @@ -1107,8 +1103,6 @@ function renderChatThreadContents( if (item.kind === "question") { return renderStreamGroup([item], { questionPrompts, - onQuestionChange: props.onQuestionChange, - onQuestionSubmit: props.onQuestionSubmit, }); } return nothing; diff --git a/ui/src/pages/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts index 058906b891e1..fe326fb3f720 100644 --- a/ui/src/pages/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -18,7 +18,6 @@ import { type CompactionStatus, type FallbackStatus, type PlanStatus, - type QuestionStatus, } from "./tool-stream.ts"; export const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000; @@ -57,7 +56,6 @@ type RunLifecycleHost = Omit< fallbackStatus?: FallbackStatus | null; fallbackClearTimer?: TimerHandle | number | null; planStatus?: PlanStatus | null; - questionStatus?: QuestionStatus | null; chatRunStatus?: ChatRunUiStatus | null; chatRunStatusClearTimer?: TimerHandle | number | null; sessionsResult?: SessionsListResult | null; @@ -237,10 +235,6 @@ function clearRunIndicators(host: RunLifecycleHost, runId?: string | null) { if (host.planStatus && (!runId || !planOwner || planOwner === runId)) { host.planStatus = null; } - const questionOwner = host.questionStatus?.runId; - if (host.questionStatus && (!runId || !questionOwner || questionOwner === runId)) { - host.questionStatus = null; - } } function sessionKeysFor(host: RunLifecycleHost, options: ReconcileOptions): Set { diff --git a/ui/src/pages/chat/tool-stream.node.test.ts b/ui/src/pages/chat/tool-stream.node.test.ts index b1eaecee085b..7062b31a79c5 100644 --- a/ui/src/pages/chat/tool-stream.node.test.ts +++ b/ui/src/pages/chat/tool-stream.node.test.ts @@ -6,7 +6,6 @@ import { resetToolStream, type FallbackStatus, type PlanStatus, - type QuestionStatus, type ToolStreamEntry, } from "./tool-stream.ts"; @@ -22,15 +21,10 @@ type MutableHost = ToolStreamHost & { fallbackStatus?: FallbackStatus | null; fallbackClearTimer?: number | null; planStatus?: PlanStatus | null; - questionStatus?: QuestionStatus | null; requestUpdate?: () => void; }; const TOOL_STREAM_TEST_NOW = new Date("2026-05-09T00:00:00.000Z").getTime(); -function testQuestionActionToken(): string { - return "12345678-1234-4123-8123-123456789abc"; -} - function createHost(overrides?: Partial): MutableHost { const modelOverrides: Record = {}; return { @@ -58,7 +52,6 @@ function createHost(overrides?: Partial): MutableHost { fallbackStatus: null, fallbackClearTimer: null, planStatus: null, - questionStatus: null, ...overrides, }; } @@ -263,75 +256,6 @@ describe("app-tool-stream plan snapshots", () => { }); }); -describe("app-tool-stream native questions", () => { - it("stores structured questions and clears only the matching item", () => { - const host = createHost(); - handleAgentEvent( - host, - agentEvent("run-1", 1, "question", { - phase: "requested", - itemId: "item-1", - actionToken: testQuestionActionToken(), - questions: [ - { - id: " mode ", - header: " Mode ", - question: " Pick one ", - isOther: true, - options: [{ label: " Fast ", description: "Quick" }, { label: "" }], - }, - ], - }), - ); - expect(host.questionStatus).toEqual({ - runId: "run-1", - itemId: "item-1", - actionToken: testQuestionActionToken(), - questions: [ - { - id: " mode ", - header: "Mode", - question: "Pick one", - isOther: true, - options: [{ label: " Fast ", description: "Quick" }], - }, - ], - }); - - handleAgentEvent( - host, - agentEvent("run-1", 2, "question", { phase: "resolved", itemId: "other" }), - ); - expect(host.questionStatus?.itemId).toBe("item-1"); - handleAgentEvent( - host, - agentEvent("run-1", 3, "question", { phase: "resolved", itemId: "item-1" }), - ); - expect(host.questionStatus).toBeNull(); - }); - - it("leaves secret questions on the warned text-reply path", () => { - const host = createHost(); - handleAgentEvent( - host, - agentEvent("run-secret", 1, "question", { - phase: "requested", - itemId: "item-secret", - actionToken: testQuestionActionToken(), - questions: [ - { - id: "secret", - header: "Secret", - question: "Enter it", - isSecret: true, - }, - ], - }), - ); - expect(host.questionStatus).toBeNull(); - }); -}); - describe("app-tool-stream fallback lifecycle handling", () => { beforeEach(() => { vi.useRealTimers(); diff --git a/ui/src/pages/chat/tool-stream.ts b/ui/src/pages/chat/tool-stream.ts index 143ddf12afa3..213f8eab3f94 100644 --- a/ui/src/pages/chat/tool-stream.ts +++ b/ui/src/pages/chat/tool-stream.ts @@ -66,7 +66,6 @@ type ToolStreamHost = { chatToolMessages: Record[]; toolStreamSyncTimer: number | null; planStatus?: PlanStatus | null; - questionStatus?: QuestionStatus | null; sessions: Pick; }; @@ -331,7 +330,6 @@ export function resetToolStream(host: ToolStreamHost) { host.chatToolMessages = []; host.chatStreamSegments = []; host.planStatus = null; - host.questionStatus = null; } export type CompactionStatus = { @@ -361,29 +359,11 @@ export type PlanStatus = { }>; }; -export type QuestionStatus = { - runId?: string; - itemId: string; - actionToken: string; - questions: Array<{ - id: string; - header: string; - question: string; - isOther: boolean; - options: Array<{ label: string; description?: string }>; - }>; -}; - type PlanHost = ToolStreamHost & { planStatus?: PlanStatus | null; requestUpdate?: () => void; }; -type QuestionHost = ToolStreamHost & { - questionStatus?: QuestionStatus | null; - requestUpdate?: () => void; -}; - type CompactionHost = ToolStreamHost & { compactionStatus?: CompactionStatus | null; compactionClearTimer?: number | null; @@ -772,71 +752,6 @@ function handlePlanEvent(host: PlanHost, payload: AgentEventPayload) { host.requestUpdate?.(); } -function parseQuestionStatus( - data: Record, - runId: string | null, -): QuestionStatus | null { - const itemId = toTrimmedString(data.itemId); - const actionToken = toTrimmedString(data.actionToken); - if (!itemId || !actionToken?.match(/^[0-9a-f-]{36}$/u) || !Array.isArray(data.questions)) { - return null; - } - // Sensitive answers cannot use the chat-send seam without becoming transcript content. - // Keep the existing warned text prompt until the dedicated question RPC exists. - if (data.questions.some((value) => readRecord(value)?.isSecret === true)) { - return null; - } - const questions = data.questions.flatMap((value) => { - const question = readRecord(value); - const id = typeof question?.id === "string" ? question.id : undefined; - const header = toTrimmedString(question?.header); - const prompt = toTrimmedString(question?.question); - if (!id?.trim() || !header || !prompt) { - return []; - } - const options = Array.isArray(question?.options) - ? question.options.flatMap((rawOption) => { - const option = readRecord(rawOption); - const label = typeof option?.label === "string" ? option.label : undefined; - const description = toTrimmedString(option?.description); - return label?.trim() ? [{ label, ...(description ? { description } : {}) }] : []; - }) - : []; - return [ - { - id, - header, - question: prompt, - isOther: question?.isOther === true, - options, - }, - ]; - }); - return questions.length > 0 - ? { ...(runId ? { runId } : {}), itemId, actionToken, questions } - : null; -} - -function handleQuestionEvent(host: QuestionHost, payload: AgentEventPayload) { - if (!resolveAcceptedSession(host, payload, { allowSessionScopedWhenIdle: true }).accepted) { - return; - } - const data = payload.data ?? {}; - const itemId = toTrimmedString(data.itemId); - if (data.phase === "resolved") { - if (!itemId || host.questionStatus?.itemId === itemId) { - host.questionStatus = null; - host.requestUpdate?.(); - } - return; - } - if (data.phase !== "requested") { - return; - } - host.questionStatus = parseQuestionStatus(data, toTrimmedString(payload.runId)); - host.requestUpdate?.(); -} - export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) { if (!payload) { return; @@ -876,11 +791,6 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo return; } - if (payload.stream === "question") { - handleQuestionEvent(host as QuestionHost, payload); - return; - } - if (payload.stream !== "tool") { return; } diff --git a/ui/src/styles/chat/question-card.css b/ui/src/styles/chat/question-card.css index 4b05938e2c79..0ee0814c33bd 100644 --- a/ui/src/styles/chat/question-card.css +++ b/ui/src/styles/chat/question-card.css @@ -1,103 +1,320 @@ -.chat-question { - display: grid; - gap: 10px; - padding: 12px; - border: 1px solid var(--border); - border-radius: 12px; - background: var(--bg-elevated); +.chat:has(.agent-chat__question-dock) .chat-thread { + padding-bottom: calc(6px + var(--chat-question-dock-height, 0px)); + scroll-padding-bottom: var(--chat-question-dock-height, 0px); } -.chat-question__topline, -.chat-question__footer { +.agent-chat__composer-shell:has(.agent-chat__question-dock) { + position: relative; + z-index: 8; + margin-top: calc(8px - var(--chat-question-dock-height, 0px)); +} + +.agent-chat__question-dock { + position: relative; + z-index: 1; + min-width: 0; +} + +.agent-chat__question-dock::before { + content: ""; + position: absolute; + right: 0; + bottom: calc(100% - 1px); + left: 0; + height: 52px; + pointer-events: none; + background: linear-gradient(to top, var(--bg), transparent); +} + +openclaw-chat-question-panel { + display: block; +} + +.chat-question-panel { + display: grid; + gap: 12px; + box-sizing: border-box; + max-height: min(58vh, 36rem); + overflow-y: auto; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--card) 94%, transparent); + box-shadow: var(--shadow-lg); + color: var(--text); + outline: none; + animation: fade-in 0.16s var(--ease-out); +} + +.chat-question-panel:focus-visible { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + box-shadow: + 0 0 0 3px var(--accent-subtle), + var(--shadow-lg); +} + +@supports (backdrop-filter: blur(1px)) { + .chat-question-panel { + backdrop-filter: blur(18px) saturate(1.35); + -webkit-backdrop-filter: blur(18px) saturate(1.35); + } +} + +.chat-question-panel--collapsed { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding: 0; + overflow: hidden; +} + +.chat-question-panel__request-nav { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: auto; + color: var(--muted); + font-family: var(--mono); + font-size: var(--control-ui-text-sm); + font-variant-numeric: tabular-nums; +} + +.chat-question-panel__request-nav button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + cursor: pointer; +} + +.chat-question-panel__request-nav button:hover, +.chat-question-panel__request-nav button:focus-visible { + background: var(--panel-hover); + color: var(--text); + outline: none; +} + +.chat-question-panel__request-nav svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; +} + +.chat-question-panel__topline, +.chat-question-panel__footer { display: flex; align-items: center; gap: 8px; } -.chat-question__title { +.chat-question-panel__title { + min-width: 0; + overflow: hidden; color: var(--text-strong); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; + font-size: var(--control-ui-text-sm); + font-weight: 650; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; } -.chat-question__countdown { +.chat-question-panel__countdown { margin-left: auto; - color: var(--text-muted); + color: var(--muted); font-family: var(--mono); - font-size: 12px; + font-size: calc(var(--control-ui-text-sm) - 1px); font-variant-numeric: tabular-nums; } -.chat-question__field { - display: grid; - gap: 8px; - margin: 0; +.chat-question-panel__collapse { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; padding: 0; border: 0; -} - -.chat-question__field legend { - padding: 0; - color: var(--text-strong); - font-weight: 650; -} - -.chat-question__prompt { - color: var(--text-muted); - font-size: 13px; -} - -.chat-question__option { - display: flex; - gap: 8px; - align-items: flex-start; - padding: 8px; - border: 1px solid var(--border-subtle); - border-radius: 8px; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); cursor: pointer; } -.chat-question__option small { +.chat-question-panel__collapse:hover, +.chat-question-panel__collapse:focus-visible { + background: var(--panel-hover); + color: var(--text); + outline: none; +} + +.chat-question-panel__collapse svg, +.chat-question-panel__chevron svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.7; +} + +.chat-question-panel__collapse svg { + transform: rotate(180deg); +} + +.chat-question-panel__heading { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.chat-question-panel__progress { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + min-height: 24px; + padding: 0 8px; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--accent) 11%, var(--panel)); + color: var(--accent); + font-family: var(--mono); + font-size: var(--control-ui-text-sm); + font-variant-numeric: tabular-nums; +} + +.chat-question-panel__prompt { + color: var(--text-strong); + font-size: var(--control-ui-text-md); + font-weight: 600; + line-height: 1.45; +} + +.chat-question-panel__options { + display: grid; + gap: 7px; +} + +.chat-question-panel__option { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + width: 100%; + padding: 10px 11px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel) 72%, transparent); + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.chat-question-panel__option:hover:not(:disabled), +.chat-question-panel__option:focus-visible { + border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); + background: var(--panel-hover); + outline: none; +} + +.chat-question-panel__option--selected { + border-color: color-mix(in srgb, var(--accent) 58%, var(--border)); + background: color-mix(in srgb, var(--accent-subtle) 55%, var(--panel)); +} + +.chat-question-panel__option:disabled { + cursor: default; + opacity: 0.7; +} + +.chat-question-panel__option-marker { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--accent); + font-size: 11px; + font-weight: 700; +} + +.chat-question-panel__option[role="radio"] .chat-question-panel__option-marker { + border-radius: var(--radius-full); +} + +.chat-question-panel__option--selected .chat-question-panel__option-marker { + border-color: var(--accent); +} + +.chat-question-panel__option-copy { + display: block; + min-width: 0; +} + +.chat-question-panel__option-copy strong { + display: block; + font-size: var(--control-ui-text-sm); + font-weight: 600; + line-height: 1.35; +} + +.chat-question-panel__option-copy small { display: block; margin-top: 2px; - color: var(--text-muted); + color: var(--muted); + font-size: calc(var(--control-ui-text-sm) - 1px); + line-height: 1.35; } -.chat-question__option:has(input:checked) { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border-subtle)); - background: color-mix(in srgb, var(--accent-subtle) 45%, var(--bg-elevated)); +.chat-question-panel__option kbd { + min-width: 18px; + color: var(--muted); + font: 12px/1 var(--mono); + text-align: center; } -.chat-question__option:has(input:disabled) { - cursor: default; - opacity: 0.72; -} - -.chat-question__option:has(input:checked:disabled) { - opacity: 1; -} - -.chat-question__other { +.chat-question-panel__other { + box-sizing: border-box; width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elevated); + color: var(--text); + font: inherit; + outline: none; } -.chat-question__footer { - min-height: 28px; +.chat-question-panel__other:focus { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: 0 0 0 3px var(--accent-subtle); } -.chat-question__status { - color: var(--text-muted); - font-size: 12px; +.chat-question-panel__other::placeholder { + color: var(--muted); } -.chat-question__error { +.chat-question-panel__footer { + min-height: 30px; + justify-content: flex-end; +} + +.chat-question-panel__error { + margin-right: auto; color: var(--danger); - font-size: 12px; + font-size: var(--control-ui-text-sm); } -.chat-question__error-dismiss { +.chat-question-panel__error-dismiss { margin-left: 4px; padding: 0 2px; border: 0; @@ -106,7 +323,101 @@ cursor: pointer; } -.chat-question__submit { - margin-left: auto; - justify-self: end; +.chat-question-panel__skip { + border-color: var(--border-subtle); + background: transparent; + color: var(--muted); +} + +.chat-question-panel__back { + margin-right: auto; + border-color: transparent; + background: transparent; + color: var(--muted); +} + +.chat-question-panel__back:hover:not(:disabled) { + color: var(--text); + background: var(--panel-hover); +} + +.chat-question-panel__skip:hover:not(:disabled) { + color: var(--text); + background: var(--panel-hover); +} + +.chat-question-panel__collapsed-button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 9px; + width: 100%; + min-height: 42px; + padding: 8px 10px 8px 12px; + border: 0; + background: transparent; + color: var(--text); + font: inherit; + font-size: var(--control-ui-text-sm); + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.chat-question-panel__collapsed-button > :first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-question-panel__chevron { + display: inline-flex; + color: var(--muted); +} + +.chat-question-summary { + display: grid; + gap: 3px; + width: min(620px, 100%); + margin: 4px 0; + padding: 7px 10px; + border-left: 2px solid color-mix(in srgb, var(--accent) 42%, var(--border)); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + background: color-mix(in srgb, var(--panel) 62%, transparent); + color: var(--muted); + font-size: var(--control-ui-text-sm); + line-height: 1.4; +} + +.chat-question-summary__line { + display: flex; + gap: 5px; + min-width: 0; +} + +.chat-question-summary__line strong { + flex: 0 0 auto; + color: var(--text); + font-weight: 600; +} + +.chat-question-summary__line span { + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .chat-question-panel { + max-height: min(62vh, 32rem); + padding: 12px; + } + + .chat-question-panel__option { + padding: 10px 9px; + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-question-panel { + animation: none; + } }