diff --git a/CHANGELOG.md b/CHANGELOG.md index 56143615b29..f8820bea39f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Changes + +- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. +- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. + ### Fixes - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. @@ -11,6 +16,9 @@ Docs: https://docs.openclaw.ai - Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. - Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei. +- Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei. +- Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference. +- Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97. ## 2026.3.12 @@ -93,6 +101,9 @@ Docs: https://docs.openclaw.ai - Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici. - ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. - Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm. +- Delivery/dedupe: trim completed direct-cron delivery cache correctly and keep mirrored transcript dedupe active even when transcript files contain malformed lines. (#44666) thanks @frankekn. +- CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @kiki830621. +- Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte. ## 2026.3.11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4bb0e17361..87ccbeff4ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ Welcome to the lobster tank! 🦞 - **Jos** - Telegram, API, Nix mode - GitHub: [@joshp123](https://github.com/joshp123) Β· X: [@jjpcodes](https://x.com/jjpcodes) -- **Ayaan Zaidi** - Telegram subsystem, iOS app +- **Ayaan Zaidi** - Telegram subsystem, Android app - GitHub: [@obviyus](https://github.com/obviyus) Β· X: [@0bviyus](https://x.com/0bviyus) - **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index 5391ff78fe7..448336d8e41 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,8 +19,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -128,96 +132,142 @@ fun ConnectTabScreen(viewModel: MainViewModel) { verticalArrangement = Arrangement.spacedBy(14.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) Text("Gateway Connection", style = mobileTitle1, color = mobileText) Text( - "One primary action. Open advanced controls only when needed.", + if (isConnected) "Your gateway is active and ready." else "Connect to your gateway to get started.", style = mobileCallout, color = mobileTextSecondary, ) } + // Status cards in a unified card group Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), - color = mobileSurface, + color = Color.White, border = BorderStroke(1.dp, mobileBorder), ) { - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) - Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = RoundedCornerShape(10.dp), + color = mobileAccentSoft, + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + modifier = Modifier.padding(8.dp).size(18.dp), + tint = mobileAccent, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) + } + } + HorizontalDivider(color = mobileBorder) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = RoundedCornerShape(10.dp), + color = if (isConnected) mobileSuccessSoft else mobileSurface, + ) { + Icon( + imageVector = Icons.Default.Cloud, + contentDescription = null, + modifier = Modifier.padding(8.dp).size(18.dp), + tint = if (isConnected) mobileSuccess else mobileTextTertiary, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Status", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(statusText, style = mobileBody, color = if (isConnected) mobileSuccess else mobileText) + } + } } } - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - color = mobileSurface, - border = BorderStroke(1.dp, mobileBorder), - ) { - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) - Text(statusText, style = mobileBody, color = mobileText) - } - } - - Button( - onClick = { - if (isConnected) { + if (isConnected) { + // Outlined secondary button when connected β€” don't scream "danger" + Button( + onClick = { viewModel.disconnect() validationText = null - return@Button - } - if (statusText.contains("operator offline", ignoreCase = true)) { + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = mobileDanger, + ), + border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)), + ) { + Icon(Icons.Default.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold)) + } + } else { + Button( + onClick = { + if (statusText.contains("operator offline", ignoreCase = true)) { + validationText = null + viewModel.refreshGatewayConnection() + return@Button + } + + val config = + resolveGatewayConnectConfig( + useSetupCode = inputMode == ConnectInputMode.SetupCode, + setupCode = setupCode, + manualHost = manualHostInput, + manualPort = manualPortInput, + manualTls = manualTlsInput, + fallbackToken = gatewayToken, + fallbackPassword = passwordInput, + ) + + if (config == null) { + validationText = + if (inputMode == ConnectInputMode.SetupCode) { + "Paste a valid setup code to connect." + } else { + "Enter a valid manual host and port to connect." + } + return@Button + } + validationText = null - viewModel.refreshGatewayConnection() - return@Button - } - - val config = - resolveGatewayConnectConfig( - useSetupCode = inputMode == ConnectInputMode.SetupCode, - setupCode = setupCode, - manualHost = manualHostInput, - manualPort = manualPortInput, - manualTls = manualTlsInput, - fallbackToken = gatewayToken, - fallbackPassword = passwordInput, - ) - - if (config == null) { - validationText = - if (inputMode == ConnectInputMode.SetupCode) { - "Paste a valid setup code to connect." - } else { - "Enter a valid manual host and port to connect." - } - return@Button - } - - validationText = null - viewModel.setManualEnabled(true) - viewModel.setManualHost(config.host) - viewModel.setManualPort(config.port) - viewModel.setManualTls(config.tls) - viewModel.setGatewayBootstrapToken(config.bootstrapToken) - if (config.token.isNotBlank()) { - viewModel.setGatewayToken(config.token) - } else if (config.bootstrapToken.isNotBlank()) { - viewModel.setGatewayToken("") - } - viewModel.setGatewayPassword(config.password) - viewModel.connectManual() - }, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(14.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = if (isConnected) mobileDanger else mobileAccent, - contentColor = Color.White, - ), - ) { - Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + viewModel.setManualEnabled(true) + viewModel.setManualHost(config.host) + viewModel.setManualPort(config.port) + viewModel.setManualTls(config.tls) + viewModel.setGatewayBootstrapToken(config.bootstrapToken) + if (config.token.isNotBlank()) { + viewModel.setGatewayToken(config.token) + } else if (config.bootstrapToken.isNotBlank()) { + viewModel.setGatewayToken("") + } + viewModel.setGatewayPassword(config.password) + viewModel.connectManual() + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + ), + ) { + Text("Connect Gateway", style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + } } Surface( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index a3f7868fa90..c7cdf8289ff 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -345,179 +345,90 @@ fun SettingsSheet(viewModel: MainViewModel) { contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - "SETTINGS", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - Text("Device Configuration", style = mobileTitle2, color = mobileText) - Text( - "Manage capabilities, permissions, and diagnostics.", - style = mobileCallout, - color = mobileTextSecondary, - ) - } - } - item { HorizontalDivider(color = mobileBorder) } - - // Order parity: Node β†’ Voice β†’ Camera β†’ Messaging β†’ Location β†’ Screen. + // ── Node ── item { Text( - "NODE", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - OutlinedTextField( - value = displayName, - onValueChange = viewModel::setDisplayName, - label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, - modifier = Modifier.fillMaxWidth(), - textStyle = mobileBody.copy(color = mobileText), - colors = settingsTextFieldColors(), - ) - } - item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) } - item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) } - item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) } - - item { HorizontalDivider(color = mobileBorder) } - - // Voice - item { - Text( - "VOICE", + "DEVICE", style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), color = mobileAccent, ) } item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Microphone permission", style = mobileHeadline) }, - supportingContent = { + Column(modifier = Modifier.settingsRowModifier()) { + OutlinedTextField( + value = displayName, + onValueChange = viewModel::setDisplayName, + label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), + ) + HorizontalDivider(color = mobileBorder) + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text("$deviceModel Β· $appVersion", style = mobileCallout, color = mobileTextSecondary) Text( - if (micPermissionGranted) { - "Granted. Use the Voice tab mic button to capture transcript while the app is open." - } else { - "Required for foreground Voice tab transcription." - }, - style = mobileCallout, + instanceId.take(8) + "…", + style = mobileCaption1.copy(fontFamily = FontFamily.Monospace), + color = mobileTextTertiary, ) - }, - trailingContent = { - Button( - onClick = { - if (micPermissionGranted) { - openAppSettings(context) - } else { - audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (micPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { - Text( - "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab while the app is open.", - style = mobileCallout, - color = mobileTextSecondary, - ) - } - - item { HorizontalDivider(color = mobileBorder) } - - // Camera - item { - Text( - "CAMERA", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Allow Camera", style = mobileHeadline) }, - supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) }, - trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, - ) - } - item { - Text( - "Tip: grant Microphone permission for video clips with audio.", - style = mobileCallout, - color = mobileTextSecondary, - ) - } - - item { HorizontalDivider(color = mobileBorder) } - - // Messaging - item { - Text( - "MESSAGING", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - val buttonLabel = - when { - !smsPermissionAvailable -> "Unavailable" - smsPermissionGranted -> "Manage" - else -> "Grant" + } } - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("SMS Permission", style = mobileHeadline) }, - supportingContent = { - Text( - if (smsPermissionAvailable) { - "Allow the gateway to send SMS from this device." - } else { - "SMS requires a device with telephony hardware." + } + + // ── Media ── + item { + Text( + "MEDIA", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + Column(modifier = Modifier.settingsRowModifier()) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Microphone", style = mobileHeadline) }, + supportingContent = { + Text( + if (micPermissionGranted) "Granted" else "Required for voice transcription.", + style = mobileCallout, + ) }, - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (!smsPermissionAvailable) return@Button - if (smsPermissionGranted) { - openAppSettings(context) - } else { - smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) + trailingContent = { + Button( + onClick = { + if (micPermissionGranted) { + openAppSettings(context) + } else { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (micPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) } }, - enabled = smsPermissionAvailable, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) - } - }, - ) - } + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Camera", style = mobileHeadline) }, + supportingContent = { Text("Photos and video clips (foreground only).", style = mobileCallout) }, + trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, + ) + } + } - item { HorizontalDivider(color = mobileBorder) } - - // Notifications + // ── Notifications & Messaging ── item { Text( "NOTIFICATIONS", @@ -526,67 +437,87 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } item { - val buttonLabel = - if (notificationsPermissionGranted) { - "Manage" - } else { - "Grant" - } - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("System Notifications", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `system.notify` and Android foreground service alerts.", - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) { - openAppSettings(context) - } else { - notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + Column(modifier = Modifier.settingsRowModifier()) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("System Notifications", style = mobileHeadline) }, + supportingContent = { + Text("Alerts and foreground service.", style = mobileCallout) + }, + trailingContent = { + Button( + onClick = { + if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) { + openAppSettings(context) + } else { + notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (notificationsPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Notification Listener", style = mobileHeadline) }, + supportingContent = { + Text("Read and interact with notifications.", style = mobileCallout) + }, + trailingContent = { + Button( + onClick = { openNotificationListenerSettings(context) }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (notificationListenerEnabled) "Manage" else "Enable", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + if (smsPermissionAvailable) { + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("SMS", style = mobileHeadline) }, + supportingContent = { + Text("Send SMS from this device.", style = mobileCallout) + }, + trailingContent = { + Button( + onClick = { + if (smsPermissionGranted) { + openAppSettings(context) + } else { + smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (smsPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) } }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) - } - }, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Notification Listener Access", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `notifications.list` and `notifications.actions`.", - style = mobileCallout, ) - }, - trailingContent = { - Button( - onClick = { openNotificationListenerSettings(context) }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (notificationListenerEnabled) "Manage" else "Enable", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) + } + } } - item { HorizontalDivider(color = mobileBorder) } - // Data access + // ── Data Access ── item { Text( "DATA ACCESS", @@ -595,142 +526,115 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Photos Permission", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `photos.latest`.", - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (photosPermissionGranted) { - openAppSettings(context) - } else { - photosPermissionLauncher.launch(photosPermission) + Column(modifier = Modifier.settingsRowModifier()) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Photos", style = mobileHeadline) }, + supportingContent = { Text("Access recent photos.", style = mobileCallout) }, + trailingContent = { + Button( + onClick = { + if (photosPermissionGranted) { + openAppSettings(context) + } else { + photosPermissionLauncher.launch(photosPermission) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (photosPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Contacts", style = mobileHeadline) }, + supportingContent = { Text("Search and add contacts.", style = mobileCallout) }, + trailingContent = { + Button( + onClick = { + if (contactsPermissionGranted) { + openAppSettings(context) + } else { + contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (contactsPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Calendar", style = mobileHeadline) }, + supportingContent = { Text("Read and create events.", style = mobileCallout) }, + trailingContent = { + Button( + onClick = { + if (calendarPermissionGranted) { + openAppSettings(context) + } else { + calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (calendarPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + if (motionAvailable) { + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Motion", style = mobileHeadline) }, + supportingContent = { Text("Track steps and activity.", style = mobileCallout) }, + trailingContent = { + val motionButtonLabel = + when { + !motionPermissionRequired -> "Manage" + motionPermissionGranted -> "Manage" + else -> "Grant" + } + Button( + onClick = { + if (!motionPermissionRequired || motionPermissionGranted) { + openAppSettings(context) + } else { + motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) } }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (photosPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Contacts Permission", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `contacts.search` and `contacts.add`.", - style = mobileCallout, ) - }, - trailingContent = { - Button( - onClick = { - if (contactsPermissionGranted) { - openAppSettings(context) - } else { - contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) - } - }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (contactsPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Calendar Permission", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `calendar.events` and `calendar.add`.", - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (calendarPermissionGranted) { - openAppSettings(context) - } else { - calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) - } - }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (calendarPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { - val motionButtonLabel = - when { - !motionAvailable -> "Unavailable" - !motionPermissionRequired -> "Manage" - motionPermissionGranted -> "Manage" - else -> "Grant" } - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Motion Permission", style = mobileHeadline) }, - supportingContent = { - Text( - if (!motionAvailable) { - "This device does not expose accelerometer or step-counter motion sensors." - } else { - "Required for `motion.activity` and `motion.pedometer`." - }, - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (!motionAvailable) return@Button - if (!motionPermissionRequired || motionPermissionGranted) { - openAppSettings(context) - } else { - motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION) - } - }, - enabled = motionAvailable, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) - } - }, - ) + } } - item { HorizontalDivider(color = mobileBorder) } - // Location + // ── Location ── item { Text( "LOCATION", @@ -739,7 +643,7 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } item { - Column(modifier = Modifier.settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) { + Column(modifier = Modifier.settingsRowModifier()) { ListItem( modifier = Modifier.fillMaxWidth(), colors = listItemColors, @@ -781,50 +685,39 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } } - item { HorizontalDivider(color = mobileBorder) } - // Screen + // ── Preferences ── item { Text( - "SCREEN", + "PREFERENCES", style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), color = mobileAccent, ) } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Prevent Sleep", style = mobileHeadline) }, - supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) }, - trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, - ) - } - - item { HorizontalDivider(color = mobileBorder) } - - // Debug item { - Text( - "DEBUG", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) }, - supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) }, - trailingContent = { - Switch( - checked = canvasDebugStatusEnabled, - onCheckedChange = viewModel::setCanvasDebugStatusEnabled, + Column(modifier = Modifier.settingsRowModifier()) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Prevent Sleep", style = mobileHeadline) }, + supportingContent = { Text("Keep screen awake while open.", style = mobileCallout) }, + trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, ) - }, - ) - } + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Debug Canvas", style = mobileHeadline) }, + supportingContent = { Text("Show status overlay on canvas.", style = mobileCallout) }, + trailingContent = { + Switch( + checked = canvasDebugStatusEnabled, + onCheckedChange = viewModel::setCanvasDebugStatusEnabled, + ) + }, + ) + } + } item { Spacer(modifier = Modifier.height(24.dp)) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt index be66f42bef3..f8e17a17c6b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt @@ -17,10 +17,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -212,19 +214,26 @@ fun VoiceTabScreen(viewModel: MainViewModel) { verticalAlignment = Alignment.CenterVertically, ) { // Speaker toggle - IconButton( - onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) }, - modifier = Modifier.size(48.dp), - colors = - IconButtonDefaults.iconButtonColors( - containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft, - ), - ) { - Icon( - imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, - contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker", - modifier = Modifier.size(22.dp), - tint = if (speakerEnabled) mobileTextSecondary else mobileDanger, + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) }, + modifier = Modifier.size(48.dp), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft, + ), + ) { + Icon( + imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, + contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker", + modifier = Modifier.size(22.dp), + tint = if (speakerEnabled) mobileTextSecondary else mobileDanger, + ) + } + Text( + if (speakerEnabled) "Speaker" else "Muted", + style = mobileCaption2, + color = if (speakerEnabled) mobileTextTertiary else mobileDanger, ) } @@ -278,8 +287,12 @@ fun VoiceTabScreen(viewModel: MainViewModel) { } } - // Invisible spacer to balance the row (same size as speaker button) - Box(modifier = Modifier.size(48.dp)) + // Invisible spacer to balance the row (matches speaker column width) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(4.dp)) + Text("", style = mobileCaption2) + } } // Status + labels @@ -292,11 +305,24 @@ fun VoiceTabScreen(viewModel: MainViewModel) { micEnabled -> "Listening" else -> "Mic off" } - Text( - "$gatewayStatus Β· $stateText", - style = mobileCaption1, - color = mobileTextSecondary, - ) + val stateColor = + when { + micEnabled -> mobileSuccess + micIsSending -> mobileAccent + else -> mobileTextSecondary + } + Surface( + shape = RoundedCornerShape(999.dp), + color = if (micEnabled) mobileSuccessSoft else mobileSurface, + border = BorderStroke(1.dp, if (micEnabled) mobileSuccess.copy(alpha = 0.3f) else mobileBorder), + ) { + Text( + "$gatewayStatus Β· $stateText", + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = stateColor, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + ) + } if (!hasMicPermission) { val showRationale = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index 9601febfa31..25fafe95073 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -78,65 +77,15 @@ fun ChatComposer( val sendBusy = pendingRunCount > 0 Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box(modifier = Modifier.weight(1f)) { - Surface( - onClick = { showThinkingMenu = true }, - shape = RoundedCornerShape(14.dp), - color = mobileAccentSoft, - border = BorderStroke(1.dp, mobileBorderStrong), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Thinking: ${thinkingLabel(thinkingLevel)}", - style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), - color = mobileText, - ) - Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary) - } - } - - DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { - ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - } - } - - SecondaryActionButton( - label = "Attach", - icon = Icons.Default.AttachFile, - enabled = true, - onClick = onPickImages, - ) - } - if (attachments.isNotEmpty()) { AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) } - HorizontalDivider(color = mobileBorder) - - Text( - text = "MESSAGE", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp), - color = mobileTextSecondary, - ) - OutlinedTextField( value = input, onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth().height(92.dp), - placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Type a message…", style = mobileBodyStyle(), color = mobileTextTertiary) }, minLines = 2, maxLines = 5, textStyle = mobileBodyStyle().copy(color = mobileText), @@ -155,26 +104,62 @@ fun ChatComposer( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SecondaryActionButton( - label = "Refresh", - icon = Icons.Default.Refresh, - enabled = true, - compact = true, - onClick = onRefresh, - ) + Box { + Surface( + onClick = { showThinkingMenu = true }, + shape = RoundedCornerShape(14.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorderStrong), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = thinkingLabel(thinkingLevel), + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileTextSecondary, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", modifier = Modifier.size(18.dp), tint = mobileTextTertiary) + } + } - SecondaryActionButton( - label = "Abort", - icon = Icons.Default.Stop, - enabled = pendingRunCount > 0, - compact = true, - onClick = onAbort, - ) + DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + } } + SecondaryActionButton( + label = "Attach", + icon = Icons.Default.AttachFile, + enabled = true, + compact = true, + onClick = onPickImages, + ) + + SecondaryActionButton( + label = "Refresh", + icon = Icons.Default.Refresh, + enabled = true, + compact = true, + onClick = onRefresh, + ) + + SecondaryActionButton( + label = "Abort", + icon = Icons.Default.Stop, + enabled = pendingRunCount > 0, + compact = true, + onClick = onAbort, + ) + + Spacer(modifier = Modifier.weight(1f)) + Button( onClick = { val text = input @@ -182,8 +167,9 @@ fun ChatComposer( onSend(text) }, enabled = canSend, - modifier = Modifier.weight(1f).height(48.dp), + modifier = Modifier.height(44.dp), shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(horizontal = 20.dp), colors = ButtonDefaults.buttonColors( containerColor = mobileAccent, @@ -198,7 +184,7 @@ fun ChatComposer( } else { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(6.dp)) Text( text = "Send", style = mobileHeadline.copy(fontWeight = FontWeight.Bold), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index 9d08352a3f0..f61195f43fb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -151,7 +151,7 @@ fun ChatPendingToolsBubble(toolCalls: List) { ChatBubbleContainer( style = bubbleStyle("assistant"), - roleLabel = "TOOLS", + roleLabel = "Tools", ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) @@ -188,7 +188,7 @@ fun ChatPendingToolsBubble(toolCalls: List) { fun ChatStreamingAssistantBubble(text: String) { ChatBubbleContainer( style = bubbleStyle("assistant").copy(borderColor = mobileAccent), - roleLabel = "ASSISTANT Β· LIVE", + roleLabel = "OpenClaw Β· Live", ) { ChatMarkdown(text = text, textColor = mobileText) } @@ -224,9 +224,9 @@ private fun bubbleStyle(role: String): ChatBubbleStyle { private fun roleLabel(role: String): String { return when (role) { - "user" -> "USER" - "system" -> "SYSTEM" - else -> "ASSISTANT" + "user" -> "You" + "system" -> "System" + else -> "OpenClaw" } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 2c09f4488b0..e20b57ac3f5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -42,12 +42,8 @@ import ai.openclaw.app.ui.mobileCallout import ai.openclaw.app.ui.mobileCaption1 import ai.openclaw.app.ui.mobileCaption2 import ai.openclaw.app.ui.mobileDanger -import ai.openclaw.app.ui.mobileSuccess -import ai.openclaw.app.ui.mobileSuccessSoft import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileTextSecondary -import ai.openclaw.app.ui.mobileWarning -import ai.openclaw.app.ui.mobileWarningSoft import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -106,7 +102,6 @@ fun ChatSheetContent(viewModel: MainViewModel) { sessionKey = sessionKey, sessions = sessions, mainSessionKey = mainSessionKey, - healthOk = healthOk, onSelectSession = { key -> viewModel.switchChatSession(key) }, ) @@ -160,77 +155,34 @@ private fun ChatThreadSelector( sessionKey: String, sessions: List, mainSessionKey: String, - healthOk: Boolean, onSelectSession: (String) -> Unit, ) { val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) - val currentSessionLabel = - friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey) - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - Text( - text = "SESSION", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), - color = mobileTextSecondary, - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (entry in sessionOptions) { + val active = entry.key == sessionKey + Surface( + onClick = { onSelectSession(entry.key) }, + shape = RoundedCornerShape(14.dp), + color = if (active) mobileAccent else Color.White, + border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { Text( - text = currentSessionLabel, - style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), - color = mobileText, + text = friendlySessionName(entry.displayName ?: entry.key), + style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) Color.White else mobileText, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), ) - ChatConnectionPill(healthOk = healthOk) } } - - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (entry in sessionOptions) { - val active = entry.key == sessionKey - Surface( - onClick = { onSelectSession(entry.key) }, - shape = RoundedCornerShape(14.dp), - color = if (active) mobileAccent else Color.White, - border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Text( - text = friendlySessionName(entry.displayName ?: entry.key), - style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), - color = if (active) Color.White else mobileText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - } - } - } - } -} - -@Composable -private fun ChatConnectionPill(healthOk: Boolean) { - Surface( - shape = RoundedCornerShape(999.dp), - color = if (healthOk) mobileSuccessSoft else mobileWarningSoft, - border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)), - ) { - Text( - text = if (healthOk) "Connected" else "Offline", - style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), - color = if (healthOk) mobileSuccess else mobileWarning, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt index eff52017624..7ada19e166b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt @@ -79,26 +79,30 @@ internal object TalkModeVoiceResolver { return withContext(Dispatchers.IO) { val url = URL("https://api.elevenlabs.io/v1/voices") val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "GET" - conn.connectTimeout = 15_000 - conn.readTimeout = 15_000 - conn.setRequestProperty("xi-api-key", apiKey) + try { + conn.requestMethod = "GET" + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.setRequestProperty("xi-api-key", apiKey) - val code = conn.responseCode - val stream = if (code >= 400) conn.errorStream else conn.inputStream - val data = stream.readBytes() - if (code >= 400) { - val message = data.toString(Charsets.UTF_8) - throw IllegalStateException("ElevenLabs voices failed: $code $message") - } + val code = conn.responseCode + val stream = if (code >= 400) conn.errorStream else conn.inputStream + val data = stream?.use { it.readBytes() } ?: byteArrayOf() + if (code >= 400) { + val message = data.toString(Charsets.UTF_8) + throw IllegalStateException("ElevenLabs voices failed: $code $message") + } - val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() - val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) - voices.mapNotNull { entry -> - val obj = entry.asObjectOrNull() ?: return@mapNotNull null - val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null - val name = obj["name"].asStringOrNull() - ElevenLabsVoice(voiceId, name) + val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() + val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) + voices.mapNotNull { entry -> + val obj = entry.asObjectOrNull() ?: return@mapNotNull null + val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null + val name = obj["name"].asStringOrNull() + ElevenLabsVoice(voiceId, name) + } + } finally { + conn.disconnect() } } } diff --git a/docker-compose.yml b/docker-compose.yml index cc7169d3a88..c0bffc64458 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} + TZ: ${OPENCLAW_TZ:-UTC} volumes: - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace @@ -65,6 +66,7 @@ services: CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} + TZ: ${OPENCLAW_TZ:-UTC} volumes: - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace diff --git a/docker-setup.sh b/docker-setup.sh index 450c2025ffa..19e5461765b 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -10,6 +10,7 @@ HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}" RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}" SANDBOX_ENABLED="" DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}" +TIMEZONE="${OPENCLAW_TZ:-}" fail() { echo "ERROR: $*" >&2 @@ -135,6 +136,11 @@ contains_disallowed_chars() { [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] } +is_valid_timezone() { + local value="$1" + [[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]] +} + validate_mount_path_value() { local label="$1" local value="$2" @@ -202,6 +208,17 @@ fi if [[ -n "$SANDBOX_ENABLED" ]]; then validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH" fi +if [[ -n "$TIMEZONE" ]]; then + if contains_disallowed_chars "$TIMEZONE"; then + fail "OPENCLAW_TZ contains unsupported control characters." + fi + if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then + fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)." + fi + if ! is_valid_timezone "$TIMEZONE"; then + fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)." + fi +fi mkdir -p "$OPENCLAW_CONFIG_DIR" mkdir -p "$OPENCLAW_WORKSPACE_DIR" @@ -224,6 +241,7 @@ export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" export OPENCLAW_SANDBOX="$SANDBOX_ENABLED" export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH" +export OPENCLAW_TZ="$TIMEZONE" # Detect Docker socket GID for sandbox group_add. DOCKER_GID="" @@ -408,7 +426,8 @@ upsert_env "$ENV_FILE" \ OPENCLAW_DOCKER_SOCKET \ DOCKER_GID \ OPENCLAW_INSTALL_DOCKER_CLI \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \ + OPENCLAW_TZ if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then echo "==> Building Docker image: $IMAGE_NAME" diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 09693589af7..bc9d435f4de 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -145,7 +145,7 @@ Configure your tunnel's ingress rules to only route the webhook path: - `audienceType: "app-url"` β†’ audience is your HTTPS webhook URL. - `audienceType: "project-number"` β†’ audience is the Cloud project number. 3. Messages are routed by space: - - DMs use session key `agent::googlechat:dm:`. + - DMs use session key `agent::googlechat:direct:`. - Spaces use session key `agent::googlechat:group:`. 4. DM access is pairing by default. Unknown senders receive a pairing code; approve with: - `openclaw pairing approve googlechat ` diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index b8c1ebfac6f..6ea2df094f0 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -60,7 +60,7 @@ openclaw sessions cleanup --dry-run openclaw sessions cleanup --agent work --dry-run openclaw sessions cleanup --all-agents --dry-run openclaw sessions cleanup --enforce -openclaw sessions cleanup --enforce --active-key "agent:main:telegram:dm:123" +openclaw sessions cleanup --enforce --active-key "agent:main:telegram:direct:123" openclaw sessions cleanup --json ``` diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 5c60655858e..2a58c15cb4d 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -191,9 +191,9 @@ the workspace is writable. See [Memory](/concepts/memory) and - Direct chats follow `session.dmScope` (default `main`). - `main`: `agent::` (continuity across devices/channels). - Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. - - `per-peer`: `agent::dm:`. - - `per-channel-peer`: `agent:::dm:`. - - `per-account-channel-peer`: `agent::::dm:` (accountId defaults to `default`). + - `per-peer`: `agent::direct:`. + - `per-channel-peer`: `agent:::direct:`. + - `per-account-channel-peer`: `agent::::direct:` (accountId defaults to `default`). - If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `` so the same person shares a session across channels. - Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). - Telegram forum topics append `:topic:` to the group id for isolation. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 1a5edfcc6e3..a1d1b482fb2 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -59,7 +59,7 @@ Bootstrap files are trimmed and appended under **Project Context** so the model - `USER.md` - `HEARTBEAT.md` - `BOOTSTRAP.md` (only on brand-new workspaces) -- `MEMORY.md` and/or `memory.md` (when present in the workspace; either or both may be injected) +- `MEMORY.md` when present, otherwise `memory.md` as a lowercase fallback All of these files are **injected into the context window** on every turn, which means they consume tokens. Keep them concise β€” especially `MEMORY.md`, which can diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index d7e5f5c25d3..0f1dd65cbbc 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -472,7 +472,7 @@ Control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) are rate openclaw gateway call config.apply --params '{ "raw": "{ agents: { defaults: { workspace: \"~/.openclaw/workspace\" } } }", "baseHash": "", - "sessionKey": "agent:main:whatsapp:dm:+15555550123" + "sessionKey": "agent:main:whatsapp:direct:+15555550123" }' ``` diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 9e85c25e687..8493e99f098 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes: - Tool list + short descriptions - Skills list (only metadata; instructions are loaded on demand with `read`) - Self-update instructions -- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected. +- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected. - Time (UTC + user timezone) - Reply tags + heartbeat behavior - Runtime metadata (host/OS/model/thinking) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index fc52ee2205e..56b9c16203c 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -251,7 +251,7 @@ describe("normalizeModelCompat", () => { }); }); - it("overrides explicit supportsDeveloperRole true on non-native endpoints", () => { + it("respects explicit supportsDeveloperRole true on non-native endpoints", () => { const model = { ...baseModel(), provider: "custom-cpa", @@ -259,10 +259,10 @@ describe("normalizeModelCompat", () => { compat: { supportsDeveloperRole: true }, }; const normalized = normalizeModelCompat(model); - expect(supportsDeveloperRole(normalized)).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(true); }); - it("overrides explicit supportsUsageInStreaming true on non-native endpoints", () => { + it("respects explicit supportsUsageInStreaming true on non-native endpoints", () => { const model = { ...baseModel(), provider: "custom-cpa", @@ -270,6 +270,18 @@ describe("normalizeModelCompat", () => { compat: { supportsUsageInStreaming: true }, }; const normalized = normalizeModelCompat(model); + expect(supportsUsageInStreaming(normalized)).toBe(true); + }); + + it("still forces flags off when not explicitly set by user", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "https://proxy.example.com/v1", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsUsageInStreaming(normalized)).toBe(false); }); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 7bad084fe57..72deb0c655f 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -55,17 +55,22 @@ export function normalizeModelCompat(model: Model): Model { // The `developer` role and stream usage chunks are OpenAI-native behaviors. // Many OpenAI-compatible backends reject `developer` and/or emit usage-only // chunks that break strict parsers expecting choices[0]. For non-native - // openai-completions endpoints, force both compat flags off. + // openai-completions endpoints, force both compat flags off β€” unless the + // user has explicitly opted in via their model config. const compat = model.compat ?? undefined; // When baseUrl is empty the pi-ai library defaults to api.openai.com, so // leave compat unchanged and let default native behavior apply. - // Note: explicit true values are intentionally overridden for non-native - // endpoints for safety. const needsForce = baseUrl ? !isOpenAINativeEndpoint(baseUrl) : false; if (!needsForce) { return model; } - if (compat?.supportsDeveloperRole === false && compat?.supportsUsageInStreaming === false) { + + // Respect explicit user overrides: if the user has set a compat flag to + // true in their model definition, they know their endpoint supports it. + const forcedDeveloperRole = compat?.supportsDeveloperRole === true; + const forcedUsageStreaming = compat?.supportsUsageInStreaming === true; + + if (forcedDeveloperRole && forcedUsageStreaming) { return model; } @@ -73,7 +78,11 @@ export function normalizeModelCompat(model: Model): Model { return { ...model, compat: compat - ? { ...compat, supportsDeveloperRole: false, supportsUsageInStreaming: false } + ? { + ...compat, + supportsDeveloperRole: forcedDeveloperRole || false, + supportsUsageInStreaming: forcedUsageStreaming || false, + } : { supportsDeveloperRole: false, supportsUsageInStreaming: false }, } as typeof model; } diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 57639c8046e..2a71e0c95a3 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -52,6 +52,21 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); + const sanitizeAnthropicHistory = async (params: { + messages: AgentMessage[]; + provider?: string; + modelApi?: string; + modelId?: string; + }) => + sanitizeSessionHistory({ + messages: params.messages, + modelApi: params.modelApi ?? "anthropic-messages", + provider: params.provider ?? "anthropic", + modelId: params.modelId ?? "claude-opus-4-6", + sessionManager: makeMockSessionManager(), + sessionId: TEST_SESSION_ID, + }); + const getAssistantMessage = (messages: AgentMessage[]) => { expect(messages[1]?.role).toBe("assistant"); return messages[1] as Extract; @@ -760,22 +775,30 @@ describe("sanitizeSessionHistory", () => { expect(types).not.toContain("thinking"); }); - it("does not drop thinking blocks for non-copilot providers", async () => { + it("drops assistant thinking blocks for anthropic replay", async () => { setNonGoogleModelApi(); const messages = makeThinkingAndTextAssistantMessages(); - const result = await sanitizeSessionHistory({ + const result = await sanitizeAnthropicHistory({ messages }); + + const assistant = getAssistantMessage(result); + expect(assistant.content).toEqual([{ type: "text", text: "hi" }]); + }); + + it("drops assistant thinking blocks for amazon-bedrock replay", async () => { + setNonGoogleModelApi(); + + const messages = makeThinkingAndTextAssistantMessages(); + + const result = await sanitizeAnthropicHistory({ messages, - modelApi: "anthropic-messages", - provider: "anthropic", - modelId: "claude-opus-4-6", - sessionManager: makeMockSessionManager(), - sessionId: TEST_SESSION_ID, + provider: "amazon-bedrock", + modelApi: "bedrock-converse-stream", }); - const types = getAssistantContentTypes(result); - expect(types).toContain("thinking"); + const assistant = getAssistantMessage(result); + expect(assistant.content).toEqual([{ type: "text", text: "hi" }]); }); it("does not drop thinking blocks for non-claude copilot models", async () => { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 3457fdf0161..274ef0ef865 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1947,9 +1947,10 @@ export async function runEmbeddedAttempt( activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn); } - // Copilot/Claude can reject persisted `thinking` blocks (e.g. thinkingSignature:"reasoning_text") - // on *any* follow-up provider call (including tool continuations). Wrap the stream function - // so every outbound request sees sanitized messages. + // Anthropic Claude endpoints can reject replayed `thinking` blocks + // (e.g. thinkingSignature:"reasoning_text") on any follow-up provider + // call, including tool continuations. Wrap the stream function so every + // outbound request sees sanitized messages. if (transcriptPolicy.dropThinkingBlocks) { const inner = activeSession.agent.streamFn; activeSession.agent.streamFn = (model, context, options) => { diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 90d2b52ff5a..ef59f025de8 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -22,7 +22,19 @@ describe("resolveProviderCapabilities", () => { transcriptToolCallIdMode: "default", transcriptToolCallIdModelHints: [], geminiThoughtSignatureModelHints: [], - dropThinkingBlockModelHints: [], + dropThinkingBlockModelHints: ["claude"], + }); + expect(resolveProviderCapabilities("amazon-bedrock")).toEqual({ + anthropicToolSchemaMode: "native", + anthropicToolChoiceMode: "native", + providerFamily: "anthropic", + preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: ["claude"], }); }); @@ -82,6 +94,18 @@ describe("resolveProviderCapabilities", () => { it("tracks provider families and model-specific transcript quirks in the registry", () => { expect(isOpenAiProviderFamily("openai")).toBe(true); expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "anthropic", + modelId: "claude-opus-4-6", + }), + ).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "amazon-bedrock", + modelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + }), + ).toBe(true); expect( shouldDropThinkingBlocksForModel({ provider: "github-copilot", diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 27aadbcd7d3..f443fac4d11 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -29,9 +29,11 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { const PROVIDER_CAPABILITIES: Record> = { anthropic: { providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], }, "amazon-bedrock": { providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], }, // kimi-coding natively supports Anthropic tool framing (input_schema); // converting to OpenAI format causes XML text fallback instead of tool_use blocks. diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 468de55953c..32f2e06311e 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -343,6 +343,35 @@ describe("subagent registry persistence", () => { expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeDefined(); }); + it("retries cleanup announce after announce flow rejects", async () => { + const persisted = createPersistedEndedRun({ + runId: "run-reject", + childSessionKey: "agent:main:subagent:reject", + task: "reject announce", + cleanup: "keep", + }); + const registryPath = await writePersistedRegistry(persisted); + + announceSpy.mockRejectedValueOnce(new Error("announce boom")); + await restartRegistryAndFlush(); + + expect(announceSpy).toHaveBeenCalledTimes(1); + const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs: Record; + }; + expect(afterFirst.runs["run-reject"].cleanupHandled).toBe(false); + expect(afterFirst.runs["run-reject"].cleanupCompletedAt).toBeUndefined(); + + announceSpy.mockResolvedValueOnce(true); + await restartRegistryAndFlush(); + + expect(announceSpy).toHaveBeenCalledTimes(2); + const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs: Record; + }; + expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toBeDefined(); + }); + it("keeps delete-mode runs retryable when announce is deferred", async () => { const persisted = createPersistedEndedRun({ runId: "run-4", diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 477544bdd3d..d9c593c3e84 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -534,6 +534,18 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor return false; } const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + const finalizeAnnounceCleanup = (didAnnounce: boolean) => { + void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce).catch((err) => { + defaultRuntime.log(`[warn] subagent cleanup finalize failed (${runId}): ${String(err)}`); + const current = subagentRuns.get(runId); + if (!current || current.cleanupCompletedAt) { + return; + } + current.cleanupHandled = false; + persistSubagentRuns(); + }); + }; + void runSubagentAnnounceFlow({ childSessionKey: entry.childSessionKey, childRunId: entry.runId, @@ -555,13 +567,13 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true, }) .then((didAnnounce) => { - void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); + finalizeAnnounceCleanup(didAnnounce); }) .catch((error) => { defaultRuntime.log( `[warn] Subagent announce flow failed during cleanup for run ${runId}: ${String(error)}`, ); - void finalizeSubagentCleanup(runId, entry.cleanup, false); + finalizeAnnounceCleanup(false); }); return true; } diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index d6d9ec5916a..46795bad1bc 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -80,9 +80,9 @@ export function resolveTranscriptPolicy(params: { }); const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions"; - // GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with - // non-binary/non-base64 signatures (e.g. thinkingSignature: "reasoning_text"). - // Drop these blocks at send-time to keep sessions usable. + // Anthropic Claude endpoints can reject replayed `thinking` blocks unless the + // original signatures are preserved byte-for-byte. Drop them at send-time to + // keep persisted sessions usable across follow-up turns. const dropThinkingBlocks = shouldDropThinkingBlocksForModel({ provider, modelId }); const needsNonImageSanitize = diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 830b44504ad..c4f1044a8d9 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -458,41 +458,24 @@ export async function ensureAgentWorkspace(params?: { }; } -async function resolveMemoryBootstrapEntries( +async function resolveMemoryBootstrapEntry( resolvedDir: string, -): Promise> { - const candidates: WorkspaceBootstrapFileName[] = [ - DEFAULT_MEMORY_FILENAME, - DEFAULT_MEMORY_ALT_FILENAME, - ]; - const entries: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = []; - for (const name of candidates) { +): Promise<{ name: WorkspaceBootstrapFileName; filePath: string } | null> { + // Prefer MEMORY.md; fall back to memory.md only when absent. + // Checking both and deduplicating via realpath is unreliable on case-insensitive + // file systems mounted in Docker (e.g. macOS volumes), where both names pass + // fs.access() but realpath does not normalise case through the mount layer, + // causing the same content to be injected twice and wasting tokens. + for (const name of [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const) { const filePath = path.join(resolvedDir, name); try { await fs.access(filePath); - entries.push({ name, filePath }); + return { name, filePath }; } catch { - // optional + // try next candidate } } - if (entries.length <= 1) { - return entries; - } - - const seen = new Set(); - const deduped: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = []; - for (const entry of entries) { - let key = entry.filePath; - try { - key = await fs.realpath(entry.filePath); - } catch {} - if (seen.has(key)) { - continue; - } - seen.add(key); - deduped.push(entry); - } - return deduped; + return null; } export async function loadWorkspaceBootstrapFiles(dir: string): Promise { @@ -532,7 +515,10 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise { @@ -368,7 +368,7 @@ Read WORKFLOW.md on startup. expect(result).not.toBeNull(); expect(result).toContain("Do startup things"); expect(result).toContain("Be safe"); - expect(result).toContain("Execute your Session Startup sequence now"); + expect(result).toContain("Run your Session Startup sequence"); }); it("custom section names are matched case-insensitively", async () => { diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 316ac3c29b1..791c1a91a19 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -136,7 +136,7 @@ export async function readPostCompactionContext( // would be misleading for deployments that use different section names. const prose = isDefaultSections ? "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " + - "Execute your Session Startup sequence now β€” read the required files before responding to the user." + "Run your Session Startup sequence β€” read the required files before responding to the user." : `Session was just compacted. The conversation summary above is a hint, NOT a substitute for your full startup sequence. ` + `Re-read the sections injected below (${displayNames.join(", ")}) and follow your configured startup procedure before responding to the user.`; diff --git a/src/auto-reply/reply/session-reset-prompt.test.ts b/src/auto-reply/reply/session-reset-prompt.test.ts index c6a1d2d9562..bf038243962 100644 --- a/src/auto-reply/reply/session-reset-prompt.test.ts +++ b/src/auto-reply/reply/session-reset-prompt.test.ts @@ -5,7 +5,7 @@ import { buildBareSessionResetPrompt } from "./session-reset-prompt.js"; describe("buildBareSessionResetPrompt", () => { it("includes the core session startup instruction", () => { const prompt = buildBareSessionResetPrompt(); - expect(prompt).toContain("Execute your Session Startup sequence now"); + expect(prompt).toContain("Run your Session Startup sequence"); expect(prompt).toContain("read the required files before responding to the user"); }); diff --git a/src/auto-reply/reply/session-reset-prompt.ts b/src/auto-reply/reply/session-reset-prompt.ts index 67e693f70b1..c903e3a688a 100644 --- a/src/auto-reply/reply/session-reset-prompt.ts +++ b/src/auto-reply/reply/session-reset-prompt.ts @@ -2,7 +2,7 @@ import { appendCronStyleCurrentTimeLine } from "../../agents/current-time.js"; import type { OpenClawConfig } from "../../config/config.js"; const BARE_SESSION_RESET_PROMPT_BASE = - "A new session was started via /new or /reset. Execute your Session Startup sequence now - read the required files before responding to the user. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning."; + "A new session was started via /new or /reset. Run your Session Startup sequence - read the required files before responding to the user. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning."; /** * Build the bare session reset prompt, appending the current date/time so agents diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 05025dc05e6..bd7d0ff1af5 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -81,7 +81,10 @@ export function registerCronAddCommand(cron: Command) { .option("--exact", "Disable cron staggering (set stagger to 0)", false) .option("--system-event ", "System event payload (main session)") .option("--message ", "Agent message payload") - .option("--thinking ", "Thinking level for agent jobs (off|minimal|low|medium|high)") + .option( + "--thinking ", + "Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)", + ) .option("--model ", "Model override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--light-context", "Use lightweight bootstrap context for agent jobs", false) diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 35bf45907f9..b2007fc3f1a 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -49,7 +49,10 @@ export function registerCronEditCommand(cron: Command) { .option("--exact", "Disable cron staggering (set stagger to 0)") .option("--system-event ", "Set systemEvent payload") .option("--message ", "Set agentTurn payload message") - .option("--thinking ", "Thinking level for agent jobs") + .option( + "--thinking ", + "Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)", + ) .option("--model ", "Model override for agent jobs") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--light-context", "Enable lightweight bootstrap context for agent jobs") diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index fdb45a0960a..e5847f3c164 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -27,7 +27,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti .option("-t, --to ", "Recipient number in E.164 used to derive the session key") .option("--session-id ", "Use an explicit session id") .option("--agent ", "Agent id (overrides routing bindings)") - .option("--thinking ", "Thinking level: off | minimal | low | medium | high") + .option("--thinking ", "Thinking level: off | minimal | low | medium | high | xhigh") .option("--verbose ", "Persist agent verbose level for the session") .option( "--channel ", diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 647986a96e0..2a1972d9040 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -315,6 +315,7 @@ describe("model compat config schema", () => { requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, requiresMistralToolIds: false, + requiresOpenAiAnthropicToolPayload: true, }, }, ], diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index dfe4b74e9b2..6866d6c10c1 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -324,6 +324,88 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); } }); + + it("does not append a duplicate delivery mirror for the same idempotency key", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + + const messageLine = JSON.parse(lines[1]); + expect(messageLine.message.idempotencyKey).toBe("mirror:test-source-message"); + expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); + }); + + it("ignores malformed transcript lines when checking mirror idempotency", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + fs.writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "session", + version: 1, + id: sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }), + "{not-json", + JSON.stringify({ + type: "message", + message: { + role: "assistant", + idempotencyKey: "mirror:test-source-message", + content: [{ type: "text", text: "Hello from delivery mirror!" }], + }, + }), + ].join("\n") + "\n", + "utf-8", + ); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + + expect(result.ok).toBe(true); + const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(3); + }); }); describe("resolveAndPersistSessionFile", () => { diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index e6a8044f5c6..aa1890de953 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -135,6 +135,7 @@ export async function appendAssistantMessageToSessionTranscript(params: { sessionKey: string; text?: string; mediaUrls?: string[]; + idempotencyKey?: string; /** Optional override for store path (mostly for tests). */ storePath?: string; }): Promise<{ ok: true; sessionFile: string } | { ok: false; reason: string }> { @@ -179,6 +180,13 @@ export async function appendAssistantMessageToSessionTranscript(params: { await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId }); + if ( + params.idempotencyKey && + (await transcriptHasIdempotencyKey(sessionFile, params.idempotencyKey)) + ) { + return { ok: true, sessionFile }; + } + const sessionManager = SessionManager.open(sessionFile); sessionManager.appendMessage({ role: "assistant", @@ -202,8 +210,34 @@ export async function appendAssistantMessageToSessionTranscript(params: { }, stopReason: "stop", timestamp: Date.now(), + ...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}), }); emitSessionTranscriptUpdate(sessionFile); return { ok: true, sessionFile }; } + +async function transcriptHasIdempotencyKey( + transcriptPath: string, + idempotencyKey: string, +): Promise { + try { + const raw = await fs.promises.readFile(transcriptPath, "utf-8"); + for (const line of raw.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + try { + const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } }; + if (parsed.message?.idempotencyKey === idempotencyKey) { + return true; + } + } catch { + continue; + } + } + } catch { + return false; + } + return false; +} diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 2c7eb20a3c6..b245b4b9c94 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -49,7 +49,11 @@ vi.mock("./subagent-followup.js", () => ({ import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { shouldEnqueueCronMainSummary } from "../heartbeat-policy.js"; -import { dispatchCronDelivery } from "./delivery-dispatch.js"; +import { + dispatchCronDelivery, + getCompletedDirectCronDeliveriesCountForTests, + resetCompletedDirectCronDeliveriesForTests, +} from "./delivery-dispatch.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; import type { RunCronAgentTurnResult } from "./run.js"; import { @@ -84,7 +88,11 @@ function makeWithRunSession() { }); } -function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested?: boolean }) { +function makeBaseParams(overrides: { + synthesizedText?: string; + deliveryRequested?: boolean; + runSessionId?: string; +}) { const resolvedDelivery = makeResolvedDelivery(); return { cfg: {} as never, @@ -98,7 +106,7 @@ function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested } as never, agentId: "main", agentSessionKey: "agent:main", - runSessionId: "run-123", + runSessionId: overrides.runSessionId ?? "run-123", runStartedAt: Date.now(), runEndedAt: Date.now(), timeoutMs: 30_000, @@ -126,6 +134,7 @@ function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested describe("dispatchCronDelivery β€” double-announce guard", () => { beforeEach(() => { vi.clearAllMocks(); + resetCompletedDirectCronDeliveriesForTests(); vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(expectsSubagentFollowup).mockReturnValue(false); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); @@ -278,6 +287,38 @@ describe("dispatchCronDelivery β€” double-announce guard", () => { expect(deliverOutboundPayloads).toHaveBeenCalledTimes(2); }); + it("keeps direct announce delivery idempotent across replay for the same run session", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]); + + const params = makeBaseParams({ synthesizedText: "Replay-safe cron update." }); + const first = await dispatchCronDelivery(params); + const second = await dispatchCronDelivery(params); + + expect(first.delivered).toBe(true); + expect(second.delivered).toBe(true); + expect(second.deliveryAttempted).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + }); + + it("prunes the completed-delivery cache back to the entry cap", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]); + + for (let i = 0; i < 2003; i += 1) { + const params = makeBaseParams({ + synthesizedText: `Replay-safe cron update ${i}.`, + runSessionId: `run-${i}`, + }); + const state = await dispatchCronDelivery(params); + expect(state.delivered).toBe(true); + } + + expect(getCompletedDirectCronDeliveriesCountForTests()).toBe(2000); + }); + it("does not retry permanent direct announce failures", async () => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); vi.mocked(countActiveDescendantRuns).mockReturnValue(0); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index a5dc0190b72..6ddddf20669 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -5,7 +5,10 @@ import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-de import type { OpenClawConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { sleepWithAbort } from "../../infra/backoff.js"; -import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; +import { + deliverOutboundPayloads, + type OutboundDeliveryResult, +} from "../../infra/outbound/deliver.js"; import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { logWarn } from "../../logger.js"; @@ -131,6 +134,91 @@ const PERMANENT_DIRECT_CRON_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ /outbound not configured for channel/i, ]; +type CompletedDirectCronDelivery = { + ts: number; + results: OutboundDeliveryResult[]; +}; + +const COMPLETED_DIRECT_CRON_DELIVERIES = new Map(); + +function cloneDeliveryResults( + results: readonly OutboundDeliveryResult[], +): OutboundDeliveryResult[] { + return results.map((result) => ({ + ...result, + ...(result.meta ? { meta: { ...result.meta } } : {}), + })); +} + +function pruneCompletedDirectCronDeliveries(now: number) { + const ttlMs = process.env.OPENCLAW_TEST_FAST === "1" ? 60_000 : 24 * 60 * 60 * 1000; + for (const [key, entry] of COMPLETED_DIRECT_CRON_DELIVERIES) { + if (now - entry.ts >= ttlMs) { + COMPLETED_DIRECT_CRON_DELIVERIES.delete(key); + } + } + const maxEntries = 2000; + if (COMPLETED_DIRECT_CRON_DELIVERIES.size <= maxEntries) { + return; + } + const entries = [...COMPLETED_DIRECT_CRON_DELIVERIES.entries()].toSorted( + (a, b) => a[1].ts - b[1].ts, + ); + const toDelete = COMPLETED_DIRECT_CRON_DELIVERIES.size - maxEntries; + for (let i = 0; i < toDelete; i += 1) { + const oldest = entries[i]; + if (!oldest) { + break; + } + COMPLETED_DIRECT_CRON_DELIVERIES.delete(oldest[0]); + } +} + +function rememberCompletedDirectCronDelivery( + idempotencyKey: string, + results: readonly OutboundDeliveryResult[], +) { + const now = Date.now(); + COMPLETED_DIRECT_CRON_DELIVERIES.set(idempotencyKey, { + ts: now, + results: cloneDeliveryResults(results), + }); + pruneCompletedDirectCronDeliveries(now); +} + +function getCompletedDirectCronDelivery( + idempotencyKey: string, +): OutboundDeliveryResult[] | undefined { + const now = Date.now(); + pruneCompletedDirectCronDeliveries(now); + const cached = COMPLETED_DIRECT_CRON_DELIVERIES.get(idempotencyKey); + if (!cached) { + return undefined; + } + return cloneDeliveryResults(cached.results); +} + +function buildDirectCronDeliveryIdempotencyKey(params: { + runSessionId: string; + delivery: SuccessfulDeliveryTarget; +}): string { + const threadId = + params.delivery.threadId == null || params.delivery.threadId === "" + ? "" + : String(params.delivery.threadId); + const accountId = params.delivery.accountId?.trim() ?? ""; + const normalizedTo = normalizeDeliveryTarget(params.delivery.channel, params.delivery.to); + return `cron-direct-delivery:v1:${params.runSessionId}:${params.delivery.channel}:${accountId}:${normalizedTo}:${threadId}`; +} + +export function resetCompletedDirectCronDeliveriesForTests() { + COMPLETED_DIRECT_CRON_DELIVERIES.clear(); +} + +export function getCompletedDirectCronDeliveriesCountForTests(): number { + return COMPLETED_DIRECT_CRON_DELIVERIES.size; +} + function summarizeDirectCronDeliveryError(error: unknown): string { if (error instanceof Error) { return error.message || "error"; @@ -221,6 +309,10 @@ export async function dispatchCronDelivery( options?: { retryTransient?: boolean }, ): Promise => { const identity = resolveAgentOutboundIdentity(params.cfgWithAgentDefaults, params.agentId); + const deliveryIdempotencyKey = buildDirectCronDeliveryIdempotencyKey({ + runSessionId: params.runSessionId, + delivery, + }); try { const payloadsForDelivery = deliveryPayloads.length > 0 @@ -240,6 +332,12 @@ export async function dispatchCronDelivery( }); } deliveryAttempted = true; + const cachedResults = getCompletedDirectCronDelivery(deliveryIdempotencyKey); + if (cachedResults) { + // Cached entries are only recorded after a successful non-empty delivery. + delivered = true; + return null; + } const deliverySession = buildOutboundSessionContext({ cfg: params.cfgWithAgentDefaults, agentId: params.agentId, @@ -273,6 +371,9 @@ export async function dispatchCronDelivery( }) : await runDelivery(); delivered = deliveryResults.length > 0; + if (delivered) { + rememberCompletedDirectCronDelivery(deliveryIdempotencyKey, deliveryResults); + } return null; } catch (err) { if (!params.deliveryBestEffort) { diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 6890e7d55a8..8d5eec70ed0 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -205,6 +205,18 @@ describe("docker-setup.sh", () => { expect(identityDirStat.isDirectory()).toBe(true); }); + it("writes OPENCLAW_TZ into .env when given a real IANA timezone", async () => { + const activeSandbox = requireSandbox(sandbox); + + const result = runDockerSetup(activeSandbox, { + OPENCLAW_TZ: "Asia/Shanghai", + }); + + expect(result.status).toBe(0); + const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); + expect(envFile).toContain("OPENCLAW_TZ=Asia/Shanghai"); + }); + it("precreates agent data dirs to avoid EACCES in container", async () => { const activeSandbox = requireSandbox(sandbox); const configDir = join(activeSandbox.rootDir, "config-agent-dirs"); @@ -411,6 +423,17 @@ describe("docker-setup.sh", () => { expect(result.stderr).toContain("OPENCLAW_HOME_VOLUME must match"); }); + it("rejects OPENCLAW_TZ values that are not present in zoneinfo", async () => { + const activeSandbox = requireSandbox(sandbox); + + const result = runDockerSetup(activeSandbox, { + OPENCLAW_TZ: "Nope/Bad", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo"); + }); + it("avoids associative arrays so the script remains Bash 3.2-compatible", async () => { const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8"); expect(script).not.toMatch(/^\s*declare -A\b/m); @@ -455,4 +478,9 @@ describe("docker-setup.sh", () => { 2, ); }); + + it("keeps docker-compose timezone env defaults aligned across services", async () => { + const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8"); + expect(compose.match(/TZ: \$\{OPENCLAW_TZ:-UTC\}/g)).toHaveLength(2); + }); }); diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 5dfa27b20ce..f3b74416c70 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -582,7 +582,7 @@ describe("gateway agent handler", () => { expect(mocks.performGatewaySessionReset).toHaveBeenCalledTimes(1); const call = readLastAgentCommandCall(); // Message is now dynamically built with current date β€” check key substrings - expect(call?.message).toContain("Execute your Session Startup sequence now"); + expect(call?.message).toContain("Run your Session Startup sequence"); expect(call?.message).toContain("Current time:"); expect(call?.message).not.toBe(BARE_SESSION_RESET_PROMPT); expect(call?.sessionId).toBe("reset-session-id"); diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 0220a4d6895..22cf527a46b 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -334,6 +334,7 @@ describe("gateway send mirroring", () => { sessionKey: "agent:main:main", text: "caption", mediaUrls: ["https://example.com/files/report.pdf?sig=1"], + idempotencyKey: "idem-2", }), }), ); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 1ec5d23c133..4dcdd1f61f9 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -268,6 +268,7 @@ export const sendHandlers: GatewayRequestHandlers = { agentId: effectiveAgentId, text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined, + idempotencyKey: idem, } : derivedRoute ? { @@ -275,6 +276,7 @@ export const sendHandlers: GatewayRequestHandlers = { agentId: effectiveAgentId, text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined, + idempotencyKey: idem, } : undefined, }); diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 755186080ba..61fff855a8f 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -287,7 +287,7 @@ describe("gateway server agent", () => { await vi.waitFor(() => expect(calls.length).toBeGreaterThan(callsBefore)); const call = (calls.at(-1)?.[0] ?? {}) as Record; expect(call.message).toBeTypeOf("string"); - expect(call.message).toContain("Execute your Session Startup sequence now"); + expect(call.message).toContain("Run your Session Startup sequence"); expect(call.message).toContain("Current time:"); expect(typeof call.sessionId).toBe("string"); expect(call.sessionId).not.toBe("sess-main-before-reset"); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index e5b24c06a8c..8e5383ea055 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -869,11 +869,15 @@ describe("deliverOutboundPayloads", () => { sessionKey: "agent:main:main", text: "caption", mediaUrls: ["https://example.com/files/report.pdf?sig=1"], + idempotencyKey: "idem-deliver-1", }, }); expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( - expect.objectContaining({ text: "report.pdf" }), + expect.objectContaining({ + text: "report.pdf", + idempotencyKey: "idem-deliver-1", + }), ); }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index caca4985370..79bbbc17179 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -38,6 +38,7 @@ import type { sendMessageWhatsApp } from "../../web/outbound.js"; import { throwIfAborted } from "./abort.js"; import { ackDelivery, enqueueDelivery, failDelivery } from "./delivery-queue.js"; import type { OutboundIdentity } from "./identity.js"; +import type { DeliveryMirror } from "./mirror.js"; import type { NormalizedOutboundPayload } from "./payloads.js"; import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; import { isPlainTextSurface, sanitizeForPlainText } from "./sanitize-text.js"; @@ -237,16 +238,7 @@ type DeliverOutboundPayloadsCoreParams = { onPayload?: (payload: NormalizedOutboundPayload) => void; /** Session/agent context used for hooks and media local-root scoping. */ session?: OutboundSessionContext; - mirror?: { - sessionKey: string; - agentId?: string; - text?: string; - mediaUrls?: string[]; - /** Whether this message is being sent in a group/channel context */ - isGroup?: boolean; - /** Group or channel identifier for correlation with received events */ - groupId?: string; - }; + mirror?: DeliveryMirror; silent?: boolean; }; @@ -820,6 +812,7 @@ async function deliverOutboundPayloadsCore( agentId: params.mirror.agentId, sessionKey: params.mirror.sessionKey, text: mirrorText, + idempotencyKey: params.mirror.idempotencyKey, }); } } diff --git a/src/infra/outbound/delivery-queue.ts b/src/infra/outbound/delivery-queue.ts index 1cbab613bc4..97c37f911e4 100644 --- a/src/infra/outbound/delivery-queue.ts +++ b/src/infra/outbound/delivery-queue.ts @@ -4,6 +4,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveStateDir } from "../../config/paths.js"; import { generateSecureUuid } from "../secure-random.js"; +import type { OutboundMirror } from "./mirror.js"; import type { OutboundChannel } from "./targets.js"; const QUEUE_DIRNAME = "delivery-queue"; @@ -18,13 +19,6 @@ const BACKOFF_MS: readonly number[] = [ 600_000, // retry 4: 10m ]; -type DeliveryMirrorPayload = { - sessionKey: string; - agentId?: string; - text?: string; - mediaUrls?: string[]; -}; - type QueuedDeliveryPayload = { channel: Exclude; to: string; @@ -40,7 +34,7 @@ type QueuedDeliveryPayload = { bestEffort?: boolean; gifPlayback?: boolean; silent?: boolean; - mirror?: DeliveryMirrorPayload; + mirror?: OutboundMirror; }; export interface QueuedDelivery extends QueuedDeliveryPayload { diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 7cebff01d90..200d4d587e1 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -15,6 +15,16 @@ vi.mock("../../channels/plugins/index.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({ resolveDefaultAgentId: () => "main", + resolveSessionAgentId: ({ + sessionKey, + }: { + sessionKey?: string; + config?: unknown; + agentId?: string; + }) => { + const match = sessionKey?.match(/^agent:([^:]+)/i); + return match?.[1] ?? "main"; + }, resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", })); @@ -71,6 +81,29 @@ describe("sendMessage", () => { ); }); + it("propagates the send idempotency key into mirrored transcript delivery", async () => { + await sendMessage({ + cfg: {}, + channel: "telegram", + to: "123456", + content: "hi", + idempotencyKey: "idem-send-1", + mirror: { + sessionKey: "agent:main:telegram:dm:123456", + }, + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + mirror: expect.objectContaining({ + sessionKey: "agent:main:telegram:dm:123456", + text: "hi", + idempotencyKey: "idem-send-1", + }), + }), + ); + }); + it("recovers telegram plugin resolution so message/send does not fail with Unknown channel: telegram", async () => { const telegramPlugin = { outbound: { deliveryMode: "direct" }, diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index f8c09538f75..8bfd6b104b5 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -16,6 +16,7 @@ import { type OutboundDeliveryResult, type OutboundSendDeps, } from "./deliver.js"; +import type { OutboundMirror } from "./mirror.js"; import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; import { buildOutboundSessionContext } from "./session-context.js"; import { resolveOutboundTarget } from "./targets.js"; @@ -47,12 +48,7 @@ type MessageSendParams = { cfg?: OpenClawConfig; gateway?: MessageGatewayOptions; idempotencyKey?: string; - mirror?: { - sessionKey: string; - agentId?: string; - text?: string; - mediaUrls?: string[]; - }; + mirror?: OutboundMirror; abortSignal?: AbortSignal; silent?: boolean; }; @@ -232,6 +228,7 @@ export async function sendMessage(params: MessageSendParams): Promise ({ sendMessage: vi.fn(), sendPoll: vi.fn(), getAgentScopedMediaLocalRoots: vi.fn(() => ["/tmp/agent-roots"]), + appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), })); vi.mock("../../channels/plugins/message-actions.js", () => ({ @@ -26,6 +27,10 @@ vi.mock("../../media/local-roots.js", async (importOriginal) => { }; }); +vi.mock("../../config/sessions.js", () => ({ + appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript, +})); + import { executePollAction, executeSendAction } from "./outbound-send-service.js"; describe("executeSendAction", () => { @@ -35,6 +40,7 @@ describe("executeSendAction", () => { mocks.sendPoll.mockClear(); mocks.getDefaultMediaLocalRoots.mockClear(); mocks.getAgentScopedMediaLocalRoots.mockClear(); + mocks.appendAssistantMessageToSessionTranscript.mockClear(); }); it("forwards ctx.agentId to sendMessage on core outbound path", async () => { @@ -127,6 +133,41 @@ describe("executeSendAction", () => { ); }); + it("passes mirror idempotency keys through plugin-handled sends", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue({ + ok: true, + value: { messageId: "msg-plugin" }, + continuePrompt: "", + output: "", + sessionId: "s1", + model: "gpt-5.2", + usage: {}, + }); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "discord", + params: { to: "channel:123", message: "hello" }, + dryRun: false, + mirror: { + sessionKey: "agent:main:discord:channel:123", + idempotencyKey: "idem-plugin-send-1", + }, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:discord:channel:123", + text: "hello", + idempotencyKey: "idem-plugin-send-1", + }), + ); + }); + it("forwards poll args to sendPoll on core outbound path", async () => { mocks.dispatchChannelMessageAction.mockResolvedValue(null); mocks.sendPoll.mockResolvedValue({ diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 0661d5ddafb..a6b27a40b4c 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -9,6 +9,7 @@ import { throwIfAborted } from "./abort.js"; import type { OutboundSendDeps } from "./deliver.js"; import type { MessagePollResult, MessageSendResult } from "./message.js"; import { sendMessage, sendPoll } from "./message.js"; +import type { OutboundMirror } from "./mirror.js"; import { extractToolPayload } from "./tool-payload.js"; export type OutboundGatewayContext = { @@ -31,12 +32,7 @@ export type OutboundSendContext = { toolContext?: ChannelThreadingToolContext; deps?: OutboundSendDeps; dryRun: boolean; - mirror?: { - sessionKey: string; - agentId?: string; - text?: string; - mediaUrls?: string[]; - }; + mirror?: OutboundMirror; abortSignal?: AbortSignal; silent?: boolean; }; @@ -115,6 +111,7 @@ export async function executeSendAction(params: { sessionKey: params.ctx.mirror.sessionKey, text: mirrorText, mediaUrls: mirrorMediaUrls, + idempotencyKey: params.ctx.mirror.idempotencyKey, }); }, });