Merge branch 'main' into main

This commit is contained in:
longman 2026-03-13 15:40:41 +08:00 committed by GitHub
commit b33f56a29a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 423 additions and 211 deletions

View File

@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. - Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
- 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. - 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. - 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.
## 2026.3.12 ## 2026.3.12

View File

@ -57,8 +57,16 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -68,6 +76,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -513,25 +522,20 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
) { ) {
Column( Column(
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
"FIRST RUN", "OpenClaw",
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp), style = onboardingDisplayStyle,
color = onboardingAccent,
)
Text(
"OpenClaw\nMobile Setup",
style = onboardingDisplayStyle.copy(lineHeight = 38.sp),
color = onboardingText, color = onboardingText,
) )
Text( Text(
"Step ${step.index} of 4", "Mobile Setup",
style = onboardingCaption1Style, style = onboardingTitle1Style,
color = onboardingAccent, color = onboardingTextSecondary,
) )
} }
StepRailWrap(current = step) StepRail(current = step)
when (step) { when (step) {
OnboardingStep.Welcome -> WelcomeStep() OnboardingStep.Welcome -> WelcomeStep()
@ -892,15 +896,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
} }
} }
@Composable
private fun StepRailWrap(current: OnboardingStep) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
HorizontalDivider(color = onboardingBorder)
StepRail(current = current)
HorizontalDivider(color = onboardingBorder)
}
}
@Composable @Composable
private fun StepRail(current: OnboardingStep) { private fun StepRail(current: OnboardingStep) {
val steps = OnboardingStep.entries val steps = OnboardingStep.entries
@ -942,11 +937,31 @@ private fun StepRail(current: OnboardingStep) {
@Composable @Composable
private fun WelcomeStep() { private fun WelcomeStep() {
StepShell(title = "What You Get") { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Bullet("Control the gateway and operator chat from one mobile surface.") FeatureCard(
Bullet("Connect with setup code and recover pairing with CLI commands.") icon = Icons.Default.Wifi,
Bullet("Enable only the permissions and capabilities you want.") title = "Connect to your gateway",
Bullet("Finish with a real connection check before entering the app.") subtitle = "Scan a QR code or enter your host manually",
accentColor = onboardingAccent,
)
FeatureCard(
icon = Icons.Default.Tune,
title = "Choose your permissions",
subtitle = "Enable only what you need, change anytime",
accentColor = Color(0xFF7C5AC7),
)
FeatureCard(
icon = Icons.Default.ChatBubble,
title = "Chat, voice, and screen",
subtitle = "Full operator control from your phone",
accentColor = onboardingSuccess,
)
FeatureCard(
icon = Icons.Default.CheckCircle,
title = "Verify your connection",
subtitle = "Live check before you enter the app",
accentColor = Color(0xFFC8841A),
)
} }
} }
@ -975,11 +990,12 @@ private fun GatewayStep(
val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } } val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } }
StepShell(title = "Gateway Connection") { StepShell(title = "Gateway Connection") {
GuideBlock(title = "Scan onboarding QR") { Text(
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) "Run `openclaw qr` on your gateway host, then scan the code with this device.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
CommandBlock("openclaw qr") CommandBlock("openclaw qr")
Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
Button( Button(
onClick = onScanQrClick, onClick = onScanQrClick,
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
@ -1023,21 +1039,6 @@ private fun GatewayStep(
AnimatedVisibility(visible = advancedOpen) { AnimatedVisibility(visible = advancedOpen) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
GuideBlock(title = "Manual setup commands") {
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
CommandBlock("openclaw qr --setup-code-only")
CommandBlock("openclaw qr --json")
Text(
"`--json` prints `setupCode` and `gatewayUrl`.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
Text(
"Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
}
GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange) GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
if (inputMode == GatewayInputMode.SetupCode) { if (inputMode == GatewayInputMode.SetupCode) {
@ -1306,14 +1307,10 @@ private fun StepShell(
title: String, title: String,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { Column(modifier = Modifier.padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
HorizontalDivider(color = onboardingBorder)
Column(modifier = Modifier.padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = onboardingTitle1Style, color = onboardingText) Text(title, style = onboardingTitle1Style, color = onboardingText)
content() content()
} }
HorizontalDivider(color = onboardingBorder)
}
} }
@Composable @Composable
@ -1378,13 +1375,15 @@ private fun PermissionsStep(
StepShell(title = "Permissions") { StepShell(title = "Permissions") {
Text( Text(
"Enable only what you need now. You can change everything later in Settings.", "Enable only what you need. You can change these anytime in Settings.",
style = onboardingCalloutStyle, style = onboardingCalloutStyle,
color = onboardingTextSecondary, color = onboardingTextSecondary,
) )
PermissionSectionHeader("System")
PermissionToggleRow( PermissionToggleRow(
title = "Gateway discovery", title = "Gateway discovery",
subtitle = if (Build.VERSION.SDK_INT >= 33) "Nearby devices" else "Location (for NSD)", subtitle = "Find gateways on your local network",
checked = enableDiscovery, checked = enableDiscovery,
granted = isPermissionGranted(context, discoveryPermission), granted = isPermissionGranted(context, discoveryPermission),
onCheckedChange = onDiscoveryChange, onCheckedChange = onDiscoveryChange,
@ -1392,7 +1391,7 @@ private fun PermissionsStep(
InlineDivider() InlineDivider()
PermissionToggleRow( PermissionToggleRow(
title = "Location", title = "Location",
subtitle = "location.get (while app is open)", subtitle = "Share device location while app is open",
checked = enableLocation, checked = enableLocation,
granted = locationGranted, granted = locationGranted,
onCheckedChange = onLocationChange, onCheckedChange = onLocationChange,
@ -1401,7 +1400,7 @@ private fun PermissionsStep(
if (Build.VERSION.SDK_INT >= 33) { if (Build.VERSION.SDK_INT >= 33) {
PermissionToggleRow( PermissionToggleRow(
title = "Notifications", title = "Notifications",
subtitle = "system.notify and foreground alerts", subtitle = "Alerts and foreground service notices",
checked = enableNotifications, checked = enableNotifications,
granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS), granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS),
onCheckedChange = onNotificationsChange, onCheckedChange = onNotificationsChange,
@ -1410,15 +1409,16 @@ private fun PermissionsStep(
} }
PermissionToggleRow( PermissionToggleRow(
title = "Notification listener", title = "Notification listener",
subtitle = "notifications.list and notifications.actions (opens Android Settings)", subtitle = "Read and act on your notifications",
checked = enableNotificationListener, checked = enableNotificationListener,
granted = notificationListenerGranted, granted = notificationListenerGranted,
onCheckedChange = onNotificationListenerChange, onCheckedChange = onNotificationListenerChange,
) )
InlineDivider()
PermissionSectionHeader("Media")
PermissionToggleRow( PermissionToggleRow(
title = "Microphone", title = "Microphone",
subtitle = "Foreground Voice tab transcription", subtitle = "Voice transcription in the Voice tab",
checked = enableMicrophone, checked = enableMicrophone,
granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO), granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO),
onCheckedChange = onMicrophoneChange, onCheckedChange = onMicrophoneChange,
@ -1426,7 +1426,7 @@ private fun PermissionsStep(
InlineDivider() InlineDivider()
PermissionToggleRow( PermissionToggleRow(
title = "Camera", title = "Camera",
subtitle = "camera.snap and camera.clip", subtitle = "Take photos and short video clips",
checked = enableCamera, checked = enableCamera,
granted = isPermissionGranted(context, Manifest.permission.CAMERA), granted = isPermissionGranted(context, Manifest.permission.CAMERA),
onCheckedChange = onCameraChange, onCheckedChange = onCameraChange,
@ -1434,15 +1434,16 @@ private fun PermissionsStep(
InlineDivider() InlineDivider()
PermissionToggleRow( PermissionToggleRow(
title = "Photos", title = "Photos",
subtitle = "photos.latest", subtitle = "Access your recent photos",
checked = enablePhotos, checked = enablePhotos,
granted = isPermissionGranted(context, photosPermission), granted = isPermissionGranted(context, photosPermission),
onCheckedChange = onPhotosChange, onCheckedChange = onPhotosChange,
) )
InlineDivider()
PermissionSectionHeader("Personal Data")
PermissionToggleRow( PermissionToggleRow(
title = "Contacts", title = "Contacts",
subtitle = "contacts.search and contacts.add", subtitle = "Search and add contacts",
checked = enableContacts, checked = enableContacts,
granted = contactsGranted, granted = contactsGranted,
onCheckedChange = onContactsChange, onCheckedChange = onContactsChange,
@ -1450,7 +1451,7 @@ private fun PermissionsStep(
InlineDivider() InlineDivider()
PermissionToggleRow( PermissionToggleRow(
title = "Calendar", title = "Calendar",
subtitle = "calendar.events and calendar.add", subtitle = "Read and create calendar events",
checked = enableCalendar, checked = enableCalendar,
granted = calendarGranted, granted = calendarGranted,
onCheckedChange = onCalendarChange, onCheckedChange = onCalendarChange,
@ -1458,7 +1459,7 @@ private fun PermissionsStep(
InlineDivider() InlineDivider()
PermissionToggleRow( PermissionToggleRow(
title = "Motion", title = "Motion",
subtitle = "motion.activity and motion.pedometer", subtitle = "Activity and step tracking",
checked = enableMotion, checked = enableMotion,
granted = motionGranted, granted = motionGranted,
onCheckedChange = onMotionChange, onCheckedChange = onMotionChange,
@ -1469,16 +1470,25 @@ private fun PermissionsStep(
InlineDivider() InlineDivider()
PermissionToggleRow( PermissionToggleRow(
title = "SMS", title = "SMS",
subtitle = "Allow gateway-triggered SMS sending", subtitle = "Send text messages via the gateway",
checked = enableSms, checked = enableSms,
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS), granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
onCheckedChange = onSmsChange, onCheckedChange = onSmsChange,
) )
} }
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
} }
} }
@Composable
private fun PermissionSectionHeader(title: String) {
Text(
title.uppercase(),
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp),
color = onboardingAccent,
modifier = Modifier.padding(top = 8.dp),
)
}
@Composable @Composable
private fun PermissionToggleRow( private fun PermissionToggleRow(
title: String, title: String,
@ -1489,6 +1499,12 @@ private fun PermissionToggleRow(
statusOverride: String? = null, statusOverride: String? = null,
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
) { ) {
val statusText = statusOverride ?: if (granted) "Granted" else "Not granted"
val statusColor = when {
statusOverride != null -> onboardingTextTertiary
granted -> onboardingSuccess
else -> onboardingWarning
}
Row( Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -1497,11 +1513,7 @@ private fun PermissionToggleRow(
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = onboardingHeadlineStyle, color = onboardingText) Text(title, style = onboardingHeadlineStyle, color = onboardingText)
Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
Text( Text(statusText, style = onboardingCaption1Style, color = statusColor)
statusOverride ?: if (granted) "Granted" else "Not granted",
style = onboardingCaption1Style,
color = if (granted) onboardingSuccess else onboardingTextSecondary,
)
} }
Switch( Switch(
checked = checked, checked = checked,
@ -1529,20 +1541,131 @@ private fun FinalStep(
enabledPermissions: String, enabledPermissions: String,
methodLabel: String, methodLabel: String,
) { ) {
StepShell(title = "Review") { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SummaryField(label = "Method", value = methodLabel) Text("Review", style = onboardingTitle1Style, color = onboardingText)
SummaryField(label = "Gateway", value = parsedGateway?.displayUrl ?: "Invalid gateway URL")
SummaryField(label = "Enabled Permissions", value = enabledPermissions) SummaryCard(
icon = Icons.Default.Link,
label = "Method",
value = methodLabel,
accentColor = onboardingAccent,
)
SummaryCard(
icon = Icons.Default.Cloud,
label = "Gateway",
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
accentColor = Color(0xFF7C5AC7),
)
SummaryCard(
icon = Icons.Default.Security,
label = "Permissions",
value = enabledPermissions,
accentColor = onboardingSuccess,
)
if (!attemptedConnect) { if (!attemptedConnect) {
Text("Press Connect to verify gateway reachability and auth.", style = onboardingCalloutStyle, color = onboardingTextSecondary) Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingAccentSoft,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingAccent.copy(alpha = 0.2f)),
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(onboardingAccent.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
tint = onboardingAccent,
modifier = Modifier.size(22.dp),
)
}
Text(
"Tap Connect to verify your gateway is reachable.",
style = onboardingCalloutStyle,
color = onboardingAccent,
)
}
}
} else if (isConnected) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color(0xFFEEF9F3),
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(onboardingSuccess.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = onboardingSuccess,
modifier = Modifier.size(22.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Connected", style = onboardingHeadlineStyle, color = onboardingSuccess)
Text(
serverName ?: remoteAddress ?: "gateway",
style = onboardingCalloutStyle,
color = onboardingSuccess.copy(alpha = 0.8f),
)
}
}
}
} else { } else {
Text("Status: $statusText", style = onboardingCalloutStyle, color = if (isConnected) onboardingSuccess else onboardingTextSecondary) Surface(
if (isConnected) { modifier = Modifier.fillMaxWidth(),
Text("Connected to ${serverName ?: remoteAddress ?: "gateway"}", style = onboardingCalloutStyle, color = onboardingSuccess) shape = RoundedCornerShape(14.dp),
} else { color = Color(0xFFFFF8EC),
GuideBlock(title = "Pairing Required") { border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) ) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(onboardingWarning.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = onboardingWarning,
modifier = Modifier.size(22.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}
CommandBlock("openclaw devices list") CommandBlock("openclaw devices list")
CommandBlock("openclaw devices approve <requestId>") CommandBlock("openclaw devices approve <requestId>")
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary) Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
@ -1553,15 +1676,46 @@ private fun FinalStep(
} }
@Composable @Composable
private fun SummaryField(label: String, value: String) { private fun SummaryCard(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { icon: ImageVector,
label: String,
value: String,
accentColor: Color,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorder),
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(accentColor.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(22.dp),
)
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
label, label.uppercase(),
style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp),
color = onboardingTextSecondary, color = onboardingTextSecondary,
) )
Text(value, style = onboardingHeadlineStyle, color = onboardingText) Text(value, style = onboardingHeadlineStyle, color = onboardingText)
HorizontalDivider(color = onboardingBorder) }
}
} }
} }
@ -1571,10 +1725,12 @@ private fun CommandBlock(command: String) {
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(onboardingCommandBg, RoundedCornerShape(12.dp)) .height(IntrinsicSize.Min)
.clip(RoundedCornerShape(12.dp))
.background(onboardingCommandBg)
.border(width = 1.dp, color = onboardingCommandBorder, shape = RoundedCornerShape(12.dp)), .border(width = 1.dp, color = onboardingCommandBorder, shape = RoundedCornerShape(12.dp)),
) { ) {
Box(modifier = Modifier.width(3.dp).height(42.dp).background(onboardingCommandAccent)) Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(onboardingCommandAccent))
Text( Text(
command, command,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
@ -1586,23 +1742,42 @@ private fun CommandBlock(command: String) {
} }
@Composable @Composable
private fun Bullet(text: String) { private fun FeatureCard(
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { icon: ImageVector,
title: String,
subtitle: String,
accentColor: Color,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorder),
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box( Box(
modifier = modifier =
Modifier Modifier
.padding(top = 7.dp) .size(42.dp)
.size(8.dp) .background(accentColor.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
.background(onboardingAccentSoft, CircleShape), contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(22.dp),
) )
Box( }
modifier = Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Modifier Text(title, style = onboardingHeadlineStyle, color = onboardingText)
.padding(top = 9.dp) Text(subtitle, style = onboardingCalloutStyle, color = onboardingTextSecondary)
.size(4.dp) }
.background(onboardingAccent, CircleShape), }
)
Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f))
} }
} }

View File

@ -0,0 +1 @@
- tests: align OpenAI Codex auth login expectations with the `gpt-5.4` default model to prevent stale CI failures. (#44367) thanks @jrrcdev

View File

@ -1,10 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as helpers from "./pi-embedded-helpers.js";
import { import {
expectGoogleModelApiFullSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks, loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager, makeMockSessionManager,
makeSimpleUserMessages, makeSimpleUserMessages,
type SanitizeSessionHistoryHarness,
sanitizeSnapshotChangedOpenAIReasoning, sanitizeSnapshotChangedOpenAIReasoning,
sanitizeWithOpenAIResponses, sanitizeWithOpenAIResponses,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; } from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
@ -15,42 +14,43 @@ vi.mock("./pi-embedded-helpers.js", async () => ({
sanitizeSessionMessagesImages: vi.fn(async (msgs) => msgs), sanitizeSessionMessagesImages: vi.fn(async (msgs) => msgs),
})); }));
type SanitizeSessionHistory = Awaited<ReturnType<typeof loadSanitizeSessionHistoryWithCleanMocks>>; let sanitizeSessionHistory: SanitizeSessionHistoryHarness["sanitizeSessionHistory"];
let sanitizeSessionHistory: SanitizeSessionHistory; let mockedHelpers: SanitizeSessionHistoryHarness["mockedHelpers"];
describe("sanitizeSessionHistory e2e smoke", () => { describe("sanitizeSessionHistory e2e smoke", () => {
const mockSessionManager = makeMockSessionManager(); const mockSessionManager = makeMockSessionManager();
const mockMessages = makeSimpleUserMessages(); const mockMessages = makeSimpleUserMessages();
beforeEach(async () => { beforeEach(async () => {
sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks(); const harness = await loadSanitizeSessionHistoryWithCleanMocks();
sanitizeSessionHistory = harness.sanitizeSessionHistory;
mockedHelpers = harness.mockedHelpers;
}); });
it("applies full sanitize policy for google model APIs", async () => { it("passes simple user-only history through for google model APIs", async () => {
await expectGoogleModelApiFullSanitizeCall({ vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(true);
sanitizeSessionHistory,
const result = await sanitizeSessionHistory({
messages: mockMessages, messages: mockMessages,
modelApi: "google-generative-ai",
provider: "google-vertex",
sessionManager: mockSessionManager, sessionManager: mockSessionManager,
}); sessionId: "test-session",
}); });
it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => { expect(result).toEqual(mockMessages);
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); });
await sanitizeWithOpenAIResponses({ it("passes simple user-only history through for openai-responses", async () => {
vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const result = await sanitizeWithOpenAIResponses({
sanitizeSessionHistory, sanitizeSessionHistory,
messages: mockMessages, messages: mockMessages,
sessionManager: mockSessionManager, sessionManager: mockSessionManager,
}); });
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( expect(result).toEqual(mockMessages);
mockMessages,
"session:history",
expect.objectContaining({
sanitizeMode: "images-only",
sanitizeToolCallIds: false,
}),
);
}); });
it("downgrades openai reasoning blocks when the model snapshot changed", async () => { it("downgrades openai reasoning blocks when the model snapshot changed", async () => {

View File

@ -1,7 +1,6 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { SessionManager } from "@mariozechner/pi-coding-agent"; import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { expect, vi } from "vitest"; import { expect, vi } from "vitest";
import * as helpers from "./pi-embedded-helpers.js";
export type SessionEntry = { type: string; customType: string; data: unknown }; export type SessionEntry = { type: string; customType: string; data: unknown };
export type SanitizeSessionHistoryFn = (params: { export type SanitizeSessionHistoryFn = (params: {
@ -13,6 +12,11 @@ export type SanitizeSessionHistoryFn = (params: {
sessionId: string; sessionId: string;
modelId?: string; modelId?: string;
}) => Promise<AgentMessage[]>; }) => Promise<AgentMessage[]>;
export type SanitizeSessionHistoryMockedHelpers = typeof import("./pi-embedded-helpers.js");
export type SanitizeSessionHistoryHarness = {
sanitizeSessionHistory: SanitizeSessionHistoryFn;
mockedHelpers: SanitizeSessionHistoryMockedHelpers;
};
export const TEST_SESSION_ID = "test-session"; export const TEST_SESSION_ID = "test-session";
export function makeModelSnapshotEntry(data: { export function makeModelSnapshotEntry(data: {
@ -54,11 +58,16 @@ export function makeSimpleUserMessages(): AgentMessage[] {
return messages as unknown as AgentMessage[]; return messages as unknown as AgentMessage[];
} }
export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise<SanitizeSessionHistoryFn> { export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise<SanitizeSessionHistoryHarness> {
vi.resetModules();
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs); const mockedHelpers = await import("./pi-embedded-helpers.js");
vi.mocked(mockedHelpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
const mod = await import("./pi-embedded-runner/google.js"); const mod = await import("./pi-embedded-runner/google.js");
return mod.sanitizeSessionHistory; return {
sanitizeSessionHistory: mod.sanitizeSessionHistory,
mockedHelpers,
};
} }
export function makeReasoningAssistantMessages(opts?: { export function makeReasoningAssistantMessages(opts?: {
@ -118,26 +127,6 @@ export function expectOpenAIResponsesStrictSanitizeCall(
); );
} }
export async function expectGoogleModelApiFullSanitizeCall(params: {
sanitizeSessionHistory: SanitizeSessionHistoryFn;
messages: AgentMessage[];
sessionManager: SessionManager;
}) {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
await params.sanitizeSessionHistory({
messages: params.messages,
modelApi: "google-generative-ai",
provider: "google-vertex",
sessionManager: params.sessionManager,
sessionId: TEST_SESSION_ID,
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
params.messages,
"session:history",
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }),
);
}
export function makeSnapshotChangedOpenAIReasoningScenario() { export function makeSnapshotChangedOpenAIReasoningScenario() {
const sessionEntries = [ const sessionEntries = [
makeModelSnapshotEntry({ makeModelSnapshotEntry({

View File

@ -1,9 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai"; import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as helpers from "./pi-embedded-helpers.js";
import { import {
expectGoogleModelApiFullSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks, loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager, makeMockSessionManager,
makeInMemorySessionManager, makeInMemorySessionManager,
@ -11,6 +9,7 @@ import {
makeReasoningAssistantMessages, makeReasoningAssistantMessages,
makeSimpleUserMessages, makeSimpleUserMessages,
sanitizeSnapshotChangedOpenAIReasoning, sanitizeSnapshotChangedOpenAIReasoning,
type SanitizeSessionHistoryHarness,
type SanitizeSessionHistoryFn, type SanitizeSessionHistoryFn,
sanitizeWithOpenAIResponses, sanitizeWithOpenAIResponses,
TEST_SESSION_ID, TEST_SESSION_ID,
@ -25,6 +24,7 @@ vi.mock("./pi-embedded-helpers.js", async () => ({
})); }));
let sanitizeSessionHistory: SanitizeSessionHistoryFn; let sanitizeSessionHistory: SanitizeSessionHistoryFn;
let mockedHelpers: SanitizeSessionHistoryHarness["mockedHelpers"];
let testTimestamp = 1; let testTimestamp = 1;
const nextTimestamp = () => testTimestamp++; const nextTimestamp = () => testTimestamp++;
@ -35,7 +35,7 @@ describe("sanitizeSessionHistory", () => {
const mockSessionManager = makeMockSessionManager(); const mockSessionManager = makeMockSessionManager();
const mockMessages = makeSimpleUserMessages(); const mockMessages = makeSimpleUserMessages();
const setNonGoogleModelApi = () => { const setNonGoogleModelApi = () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
}; };
const sanitizeGithubCopilotHistory = async (params: { const sanitizeGithubCopilotHistory = async (params: {
@ -164,21 +164,29 @@ describe("sanitizeSessionHistory", () => {
beforeEach(async () => { beforeEach(async () => {
testTimestamp = 1; testTimestamp = 1;
sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks(); const harness = await loadSanitizeSessionHistoryWithCleanMocks();
sanitizeSessionHistory = harness.sanitizeSessionHistory;
mockedHelpers = harness.mockedHelpers;
}); });
it("sanitizes tool call ids for Google model APIs", async () => { it("passes simple user-only history through for Google model APIs", async () => {
await expectGoogleModelApiFullSanitizeCall({ vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(true);
sanitizeSessionHistory,
const result = await sanitizeSessionHistory({
messages: mockMessages, messages: mockMessages,
modelApi: "google-generative-ai",
provider: "google-vertex",
sessionManager: mockSessionManager, sessionManager: mockSessionManager,
}); sessionId: TEST_SESSION_ID,
}); });
it("sanitizes tool call ids with strict9 for Mistral models", async () => { expect(result).toEqual(mockMessages);
});
it("passes simple user-only history through for Mistral models", async () => {
setNonGoogleModelApi(); setNonGoogleModelApi();
await sanitizeSessionHistory({ const result = await sanitizeSessionHistory({
messages: mockMessages, messages: mockMessages,
modelApi: "openai-responses", modelApi: "openai-responses",
provider: "openrouter", provider: "openrouter",
@ -187,21 +195,13 @@ describe("sanitizeSessionHistory", () => {
sessionId: TEST_SESSION_ID, sessionId: TEST_SESSION_ID,
}); });
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( expect(result).toEqual(mockMessages);
mockMessages,
"session:history",
expect.objectContaining({
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict9",
}),
);
}); });
it("sanitizes tool call ids for Anthropic APIs", async () => { it("passes simple user-only history through for Anthropic APIs", async () => {
setNonGoogleModelApi(); setNonGoogleModelApi();
await sanitizeSessionHistory({ const result = await sanitizeSessionHistory({
messages: mockMessages, messages: mockMessages,
modelApi: "anthropic-messages", modelApi: "anthropic-messages",
provider: "anthropic", provider: "anthropic",
@ -209,33 +209,25 @@ describe("sanitizeSessionHistory", () => {
sessionId: TEST_SESSION_ID, sessionId: TEST_SESSION_ID,
}); });
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( expect(result).toEqual(mockMessages);
mockMessages,
"session:history",
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }),
);
}); });
it("does not sanitize tool call ids for openai-responses", async () => { it("passes simple user-only history through for openai-responses", async () => {
setNonGoogleModelApi(); setNonGoogleModelApi();
await sanitizeWithOpenAIResponses({ const result = await sanitizeWithOpenAIResponses({
sanitizeSessionHistory, sanitizeSessionHistory,
messages: mockMessages, messages: mockMessages,
sessionManager: mockSessionManager, sessionManager: mockSessionManager,
}); });
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( expect(result).toEqual(mockMessages);
mockMessages,
"session:history",
expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }),
);
}); });
it("sanitizes tool call ids for openai-completions", async () => { it("passes simple user-only history through for openai-completions", async () => {
setNonGoogleModelApi(); setNonGoogleModelApi();
await sanitizeSessionHistory({ const result = await sanitizeSessionHistory({
messages: mockMessages, messages: mockMessages,
modelApi: "openai-completions", modelApi: "openai-completions",
provider: "openai", provider: "openai",
@ -244,15 +236,7 @@ describe("sanitizeSessionHistory", () => {
sessionId: TEST_SESSION_ID, sessionId: TEST_SESSION_ID,
}); });
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( expect(result).toEqual(mockMessages);
mockMessages,
"session:history",
expect.objectContaining({
sanitizeMode: "images-only",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
}),
);
}); });
it("prepends a bootstrap user turn for strict OpenAI-compatible assistant-first history", async () => { it("prepends a bootstrap user turn for strict OpenAI-compatible assistant-first history", async () => {
@ -314,7 +298,7 @@ describe("sanitizeSessionHistory", () => {
}); });
it("drops stale assistant usage snapshots kept before latest compaction summary", async () => { it("drops stale assistant usage snapshots kept before latest compaction summary", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([ const messages = castAgentMessages([
{ role: "user", content: "old context" }, { role: "user", content: "old context" },
@ -335,7 +319,7 @@ describe("sanitizeSessionHistory", () => {
}); });
it("preserves fresh assistant usage snapshots created after latest compaction summary", async () => { it("preserves fresh assistant usage snapshots created after latest compaction summary", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([ const messages = castAgentMessages([
makeAssistantUsageMessage({ makeAssistantUsageMessage({
@ -359,7 +343,7 @@ describe("sanitizeSessionHistory", () => {
}); });
it("adds a zeroed assistant usage snapshot when usage is missing", async () => { it("adds a zeroed assistant usage snapshot when usage is missing", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([ const messages = castAgentMessages([
{ role: "user", content: "question" }, { role: "user", content: "question" },
@ -378,7 +362,7 @@ describe("sanitizeSessionHistory", () => {
}); });
it("normalizes mixed partial assistant usage fields to numeric totals", async () => { it("normalizes mixed partial assistant usage fields to numeric totals", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([ const messages = castAgentMessages([
{ role: "user", content: "question" }, { role: "user", content: "question" },
@ -407,7 +391,7 @@ describe("sanitizeSessionHistory", () => {
}); });
it("preserves existing usage cost while normalizing token fields", async () => { it("preserves existing usage cost while normalizing token fields", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([ const messages = castAgentMessages([
{ role: "user", content: "question" }, { role: "user", content: "question" },
@ -451,7 +435,7 @@ describe("sanitizeSessionHistory", () => {
}); });
it("preserves unknown cost when token fields already match", async () => { it("preserves unknown cost when token fields already match", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([ const messages = castAgentMessages([
{ role: "user", content: "question" }, { role: "user", content: "question" },
@ -484,7 +468,7 @@ describe("sanitizeSessionHistory", () => {
}); });
it("drops stale usage when compaction summary appears before kept assistant messages", async () => { it("drops stale usage when compaction summary appears before kept assistant messages", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z"); const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
const messages = castAgentMessages([ const messages = castAgentMessages([
@ -505,7 +489,7 @@ describe("sanitizeSessionHistory", () => {
}); });
it("keeps fresh usage after compaction timestamp in summary-first ordering", async () => { it("keeps fresh usage after compaction timestamp in summary-first ordering", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z"); const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
const messages = castAgentMessages([ const messages = castAgentMessages([

View File

@ -183,7 +183,7 @@ describe("modelsAuthLoginCommand", () => {
"Auth profile: openai-codex:user@example.com (openai-codex/oauth)", "Auth profile: openai-codex:user@example.com (openai-codex/oauth)",
); );
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
"Default model available: openai-codex/gpt-5.3-codex (use --set-default to apply)", "Default model available: openai-codex/gpt-5.4 (use --set-default to apply)",
); );
}); });
@ -193,9 +193,9 @@ describe("modelsAuthLoginCommand", () => {
await modelsAuthLoginCommand({ provider: "openai-codex", setDefault: true }, runtime); await modelsAuthLoginCommand({ provider: "openai-codex", setDefault: true }, runtime);
expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({
primary: "openai-codex/gpt-5.3-codex", primary: "openai-codex/gpt-5.4",
}); });
expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.3-codex"); expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4");
}); });
it("keeps existing plugin error behavior for non built-in providers", async () => { it("keeps existing plugin error behavior for non built-in providers", async () => {

View File

@ -266,6 +266,7 @@ describe("gateway server sessions", () => {
lastChannel: "whatsapp", lastChannel: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
lastAccountId: "work", lastAccountId: "work",
lastThreadId: "1737500000.123456",
}, },
"discord:group:dev": { "discord:group:dev": {
sessionId: "sess-group", sessionId: "sess-group",
@ -336,6 +337,7 @@ describe("gateway server sessions", () => {
channel: "whatsapp", channel: "whatsapp",
to: "+1555", to: "+1555",
accountId: "work", accountId: "work",
threadId: "1737500000.123456",
}); });
const active = await rpcReq<{ const active = await rpcReq<{
@ -545,13 +547,27 @@ describe("gateway server sessions", () => {
const reset = await rpcReq<{ const reset = await rpcReq<{
ok: true; ok: true;
key: string; key: string;
entry: { sessionId: string; modelProvider?: string; model?: string }; entry: {
sessionId: string;
modelProvider?: string;
model?: string;
lastAccountId?: string;
lastThreadId?: string | number;
};
}>(ws, "sessions.reset", { key: "agent:main:main" }); }>(ws, "sessions.reset", { key: "agent:main:main" });
expect(reset.ok).toBe(true); expect(reset.ok).toBe(true);
expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.key).toBe("agent:main:main");
expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
expect(reset.payload?.entry.modelProvider).toBe("openai"); expect(reset.payload?.entry.modelProvider).toBe("openai");
expect(reset.payload?.entry.model).toBe("gpt-test-a"); expect(reset.payload?.entry.model).toBe("gpt-test-a");
expect(reset.payload?.entry.lastAccountId).toBe("work");
expect(reset.payload?.entry.lastThreadId).toBe("1737500000.123456");
const storeAfterReset = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{ lastAccountId?: string; lastThreadId?: string | number }
>;
expect(storeAfterReset["agent:main:main"]?.lastAccountId).toBe("work");
expect(storeAfterReset["agent:main:main"]?.lastThreadId).toBe("1737500000.123456");
const filesAfterReset = await fs.readdir(dir); const filesAfterReset = await fs.readdir(dir);
expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true); expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true);

View File

@ -338,6 +338,8 @@ export async function performGatewaySessionReset(params: {
origin: snapshotSessionOrigin(currentEntry), origin: snapshotSessionOrigin(currentEntry),
lastChannel: currentEntry?.lastChannel, lastChannel: currentEntry?.lastChannel,
lastTo: currentEntry?.lastTo, lastTo: currentEntry?.lastTo,
lastAccountId: currentEntry?.lastAccountId,
lastThreadId: currentEntry?.lastThreadId,
skillsSnapshot: currentEntry?.skillsSnapshot, skillsSnapshot: currentEntry?.skillsSnapshot,
inputTokens: 0, inputTokens: 0,
outputTokens: 0, outputTokens: 0,

View File

@ -58,6 +58,22 @@ function maybeBootstrapChannelPlugin(params: {
} }
} }
function resolveDirectFromActiveRegistry(
channel: DeliverableMessageChannel,
): ChannelPlugin | undefined {
const activeRegistry = getActivePluginRegistry();
if (!activeRegistry) {
return undefined;
}
for (const entry of activeRegistry.channels) {
const plugin = entry?.plugin;
if (plugin?.id === channel) {
return plugin;
}
}
return undefined;
}
export function resolveOutboundChannelPlugin(params: { export function resolveOutboundChannelPlugin(params: {
channel: string; channel: string;
cfg?: OpenClawConfig; cfg?: OpenClawConfig;
@ -72,7 +88,11 @@ export function resolveOutboundChannelPlugin(params: {
if (current) { if (current) {
return current; return current;
} }
const directCurrent = resolveDirectFromActiveRegistry(normalized);
if (directCurrent) {
return directCurrent;
}
maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg });
return resolve(); return resolve() ?? resolveDirectFromActiveRegistry(normalized);
} }

View File

@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { import {
resolveHeartbeatDeliveryTarget, resolveHeartbeatDeliveryTarget,
resolveOutboundTarget, resolveOutboundTarget,
@ -64,6 +67,27 @@ describe("resolveOutboundTarget defaultTo config fallback", () => {
}); });
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
}); });
it("falls back to the active registry when the cached channel map is stale", () => {
const registry = createTestRegistry([]);
setActivePluginRegistry(registry, "stale-registry-test");
// Warm the cached channel map before mutating the registry in place.
expect(resolveOutboundTarget({ channel: "telegram", to: "123", mode: "explicit" }).ok).toBe(
false,
);
registry.channels.push({
pluginId: "telegram",
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
source: "test",
});
expect(resolveOutboundTarget({ channel: "telegram", to: "123", mode: "explicit" })).toEqual({
ok: true,
to: "123",
});
});
}); });
describe("resolveSessionDeliveryTarget", () => { describe("resolveSessionDeliveryTarget", () => {