Merge branch 'main' into feat/vnc
This commit is contained in:
commit
c20171a3d3
11
CHANGELOG.md
11
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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)) }
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -151,7 +151,7 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||
|
||||
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<ChatPendingToolCall>) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<ChatSessionEntry>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:<agentId>:googlechat:dm:<spaceId>`.
|
||||
- DMs use session key `agent:<agentId>:googlechat:direct:<spaceId>`.
|
||||
- Spaces use session key `agent:<agentId>:googlechat:group:<spaceId>`.
|
||||
4. DM access is pairing by default. Unknown senders receive a pairing code; approve with:
|
||||
- `openclaw pairing approve googlechat <code>`
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -191,9 +191,9 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Direct chats follow `session.dmScope` (default `main`).
|
||||
- `main`: `agent:<agentId>:<mainKey>` (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:<agentId>:dm:<peerId>`.
|
||||
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
|
||||
- `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:dm:<peerId>` (accountId defaults to `default`).
|
||||
- `per-peer`: `agent:<agentId>:direct:<peerId>`.
|
||||
- `per-channel-peer`: `agent:<agentId>:<channel>:direct:<peerId>`.
|
||||
- `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:direct:<peerId>` (accountId defaults to `default`).
|
||||
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
|
||||
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
||||
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": "<hash>",
|
||||
"sessionKey": "agent:main:whatsapp:dm:+15555550123"
|
||||
"sessionKey": "agent:main:whatsapp:direct:+15555550123"
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -55,17 +55,22 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
// 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<Api>): Model<Api> {
|
||||
return {
|
||||
...model,
|
||||
compat: compat
|
||||
? { ...compat, supportsDeveloperRole: false, supportsUsageInStreaming: false }
|
||||
? {
|
||||
...compat,
|
||||
supportsDeveloperRole: forcedDeveloperRole || false,
|
||||
supportsUsageInStreaming: forcedUsageStreaming || false,
|
||||
}
|
||||
: { supportsDeveloperRole: false, supportsUsageInStreaming: false },
|
||||
} as typeof model;
|
||||
}
|
||||
|
||||
@ -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<AgentMessage, { role: "assistant" }>;
|
||||
@ -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 () => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -29,9 +29,11 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
|
||||
const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
|
||||
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.
|
||||
|
||||
@ -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<string, { cleanupHandled?: boolean; cleanupCompletedAt?: number }>;
|
||||
};
|
||||
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<string, { cleanupCompletedAt?: number }>;
|
||||
};
|
||||
expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps delete-mode runs retryable when announce is deferred", async () => {
|
||||
const persisted = createPersistedEndedRun({
|
||||
runId: "run-4",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -458,41 +458,24 @@ export async function ensureAgentWorkspace(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveMemoryBootstrapEntries(
|
||||
async function resolveMemoryBootstrapEntry(
|
||||
resolvedDir: string,
|
||||
): Promise<Array<{ name: WorkspaceBootstrapFileName; filePath: string }>> {
|
||||
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<string>();
|
||||
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<WorkspaceBootstrapFile[]> {
|
||||
@ -532,7 +515,10 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
|
||||
},
|
||||
];
|
||||
|
||||
entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir)));
|
||||
const memoryEntry = await resolveMemoryBootstrapEntry(resolvedDir);
|
||||
if (memoryEntry) {
|
||||
entries.push(memoryEntry);
|
||||
}
|
||||
|
||||
const result: WorkspaceBootstrapFile[] = [];
|
||||
for (const entry of entries) {
|
||||
|
||||
@ -363,7 +363,7 @@ export async function runGreetingPromptForBareNewOrReset(params: {
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
|
||||
const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? "";
|
||||
expect(prompt).toContain("A new session was started via /new or /reset");
|
||||
expect(prompt).toContain("Execute your Session Startup sequence now");
|
||||
expect(prompt).toContain("Run your Session Startup sequence");
|
||||
}
|
||||
|
||||
export function installTriggerHandlingE2eTestHooks() {
|
||||
|
||||
@ -332,7 +332,7 @@ Read WORKFLOW.md on startup.
|
||||
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
|
||||
const result = await readPostCompactionContext(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain("Execute your Session Startup sequence now");
|
||||
expect(result).toContain("Run your Session Startup sequence");
|
||||
});
|
||||
|
||||
it("falls back to legacy sections when defaults are explicitly configured", async () => {
|
||||
@ -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 () => {
|
||||
|
||||
@ -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.`;
|
||||
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -81,7 +81,10 @@ export function registerCronAddCommand(cron: Command) {
|
||||
.option("--exact", "Disable cron staggering (set stagger to 0)", false)
|
||||
.option("--system-event <text>", "System event payload (main session)")
|
||||
.option("--message <text>", "Agent message payload")
|
||||
.option("--thinking <level>", "Thinking level for agent jobs (off|minimal|low|medium|high)")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
|
||||
)
|
||||
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
|
||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||
.option("--light-context", "Use lightweight bootstrap context for agent jobs", false)
|
||||
|
||||
@ -49,7 +49,10 @@ export function registerCronEditCommand(cron: Command) {
|
||||
.option("--exact", "Disable cron staggering (set stagger to 0)")
|
||||
.option("--system-event <text>", "Set systemEvent payload")
|
||||
.option("--message <text>", "Set agentTurn payload message")
|
||||
.option("--thinking <level>", "Thinking level for agent jobs")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
|
||||
)
|
||||
.option("--model <model>", "Model override for agent jobs")
|
||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||
.option("--light-context", "Enable lightweight bootstrap context for agent jobs")
|
||||
|
||||
@ -27,7 +27,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
|
||||
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
|
||||
.option("--session-id <id>", "Use an explicit session id")
|
||||
.option("--agent <id>", "Agent id (overrides routing bindings)")
|
||||
.option("--thinking <level>", "Thinking level: off | minimal | low | medium | high")
|
||||
.option("--thinking <level>", "Thinking level: off | minimal | low | medium | high | xhigh")
|
||||
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
||||
.option(
|
||||
"--channel <channel>",
|
||||
|
||||
@ -315,6 +315,7 @@ describe("model compat config schema", () => {
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresThinkingAsText: false,
|
||||
requiresMistralToolIds: false,
|
||||
requiresOpenAiAnthropicToolPayload: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<string, CompletedDirectCronDelivery>();
|
||||
|
||||
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<RunCronAgentTurnResult | null> => {
|
||||
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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -287,7 +287,7 @@ describe("gateway server agent", () => {
|
||||
await vi.waitFor(() => expect(calls.length).toBeGreaterThan(callsBefore));
|
||||
const call = (calls.at(-1)?.[0] ?? {}) as Record<string, unknown>;
|
||||
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");
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<OutboundChannel, "none">;
|
||||
to: string;
|
||||
@ -40,7 +34,7 @@ type QueuedDeliveryPayload = {
|
||||
bestEffort?: boolean;
|
||||
gifPlayback?: boolean;
|
||||
silent?: boolean;
|
||||
mirror?: DeliveryMirrorPayload;
|
||||
mirror?: OutboundMirror;
|
||||
};
|
||||
|
||||
export interface QueuedDelivery extends QueuedDeliveryPayload {
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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<MessageSen
|
||||
...params.mirror,
|
||||
text: mirrorText || params.content,
|
||||
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
||||
idempotencyKey: params.mirror.idempotencyKey ?? params.idempotencyKey,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
14
src/infra/outbound/mirror.ts
Normal file
14
src/infra/outbound/mirror.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export type OutboundMirror = {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
export type DeliveryMirror = OutboundMirror & {
|
||||
/** Whether this message is being sent in a group/channel context */
|
||||
isGroup?: boolean;
|
||||
/** Group or channel identifier for correlation with received events */
|
||||
groupId?: string;
|
||||
};
|
||||
@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
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({
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user