diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b3d855300..56143615b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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. ## 2026.3.12 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index dc33bdb6836..bf4f723c242 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -57,8 +57,16 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material.icons.Icons 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.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.DisposableEffect import androidx.compose.runtime.collectAsState @@ -68,6 +76,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -513,25 +522,20 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { ) { Column( modifier = Modifier.padding(top = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - "FIRST RUN", - style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp), - color = onboardingAccent, - ) - Text( - "OpenClaw\nMobile Setup", - style = onboardingDisplayStyle.copy(lineHeight = 38.sp), + "OpenClaw", + style = onboardingDisplayStyle, color = onboardingText, ) Text( - "Step ${step.index} of 4", - style = onboardingCaption1Style, - color = onboardingAccent, + "Mobile Setup", + style = onboardingTitle1Style, + color = onboardingTextSecondary, ) } - StepRailWrap(current = step) + StepRail(current = step) when (step) { 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 private fun StepRail(current: OnboardingStep) { val steps = OnboardingStep.entries @@ -942,11 +937,31 @@ private fun StepRail(current: OnboardingStep) { @Composable private fun WelcomeStep() { - StepShell(title = "What You Get") { - Bullet("Control the gateway and operator chat from one mobile surface.") - Bullet("Connect with setup code and recover pairing with CLI commands.") - Bullet("Enable only the permissions and capabilities you want.") - Bullet("Finish with a real connection check before entering the app.") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + FeatureCard( + icon = Icons.Default.Wifi, + title = "Connect to your gateway", + 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 } } StepShell(title = "Gateway Connection") { - GuideBlock(title = "Scan onboarding QR") { - Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) - CommandBlock("openclaw qr") - Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary) - } + Text( + "Run `openclaw qr` on your gateway host, then scan the code with this device.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + CommandBlock("openclaw qr") Button( onClick = onScanQrClick, modifier = Modifier.fillMaxWidth().height(48.dp), @@ -1023,21 +1039,6 @@ private fun GatewayStep( AnimatedVisibility(visible = advancedOpen) { 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) if (inputMode == GatewayInputMode.SetupCode) { @@ -1306,13 +1307,9 @@ private fun StepShell( title: String, content: @Composable ColumnScope.() -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { - HorizontalDivider(color = onboardingBorder) - Column(modifier = Modifier.padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(title, style = onboardingTitle1Style, color = onboardingText) - content() - } - HorizontalDivider(color = onboardingBorder) + Column(modifier = Modifier.padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = onboardingTitle1Style, color = onboardingText) + content() } } @@ -1378,13 +1375,15 @@ private fun PermissionsStep( StepShell(title = "Permissions") { 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, color = onboardingTextSecondary, ) + + PermissionSectionHeader("System") PermissionToggleRow( 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, granted = isPermissionGranted(context, discoveryPermission), onCheckedChange = onDiscoveryChange, @@ -1392,7 +1391,7 @@ private fun PermissionsStep( InlineDivider() PermissionToggleRow( title = "Location", - subtitle = "location.get (while app is open)", + subtitle = "Share device location while app is open", checked = enableLocation, granted = locationGranted, onCheckedChange = onLocationChange, @@ -1401,7 +1400,7 @@ private fun PermissionsStep( if (Build.VERSION.SDK_INT >= 33) { PermissionToggleRow( title = "Notifications", - subtitle = "system.notify and foreground alerts", + subtitle = "Alerts and foreground service notices", checked = enableNotifications, granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS), onCheckedChange = onNotificationsChange, @@ -1410,15 +1409,16 @@ private fun PermissionsStep( } PermissionToggleRow( title = "Notification listener", - subtitle = "notifications.list and notifications.actions (opens Android Settings)", + subtitle = "Read and act on your notifications", checked = enableNotificationListener, granted = notificationListenerGranted, onCheckedChange = onNotificationListenerChange, ) - InlineDivider() + + PermissionSectionHeader("Media") PermissionToggleRow( title = "Microphone", - subtitle = "Foreground Voice tab transcription", + subtitle = "Voice transcription in the Voice tab", checked = enableMicrophone, granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO), onCheckedChange = onMicrophoneChange, @@ -1426,7 +1426,7 @@ private fun PermissionsStep( InlineDivider() PermissionToggleRow( title = "Camera", - subtitle = "camera.snap and camera.clip", + subtitle = "Take photos and short video clips", checked = enableCamera, granted = isPermissionGranted(context, Manifest.permission.CAMERA), onCheckedChange = onCameraChange, @@ -1434,15 +1434,16 @@ private fun PermissionsStep( InlineDivider() PermissionToggleRow( title = "Photos", - subtitle = "photos.latest", + subtitle = "Access your recent photos", checked = enablePhotos, granted = isPermissionGranted(context, photosPermission), onCheckedChange = onPhotosChange, ) - InlineDivider() + + PermissionSectionHeader("Personal Data") PermissionToggleRow( title = "Contacts", - subtitle = "contacts.search and contacts.add", + subtitle = "Search and add contacts", checked = enableContacts, granted = contactsGranted, onCheckedChange = onContactsChange, @@ -1450,7 +1451,7 @@ private fun PermissionsStep( InlineDivider() PermissionToggleRow( title = "Calendar", - subtitle = "calendar.events and calendar.add", + subtitle = "Read and create calendar events", checked = enableCalendar, granted = calendarGranted, onCheckedChange = onCalendarChange, @@ -1458,7 +1459,7 @@ private fun PermissionsStep( InlineDivider() PermissionToggleRow( title = "Motion", - subtitle = "motion.activity and motion.pedometer", + subtitle = "Activity and step tracking", checked = enableMotion, granted = motionGranted, onCheckedChange = onMotionChange, @@ -1469,16 +1470,25 @@ private fun PermissionsStep( InlineDivider() PermissionToggleRow( title = "SMS", - subtitle = "Allow gateway-triggered SMS sending", + subtitle = "Send text messages via the gateway", checked = enableSms, granted = isPermissionGranted(context, Manifest.permission.SEND_SMS), 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 private fun PermissionToggleRow( title: String, @@ -1489,6 +1499,12 @@ private fun PermissionToggleRow( statusOverride: String? = null, onCheckedChange: (Boolean) -> Unit, ) { + val statusText = statusOverride ?: if (granted) "Granted" else "Not granted" + val statusColor = when { + statusOverride != null -> onboardingTextTertiary + granted -> onboardingSuccess + else -> onboardingWarning + } Row( modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp), verticalAlignment = Alignment.CenterVertically, @@ -1497,11 +1513,7 @@ private fun PermissionToggleRow( Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(title, style = onboardingHeadlineStyle, color = onboardingText) Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) - Text( - statusOverride ?: if (granted) "Granted" else "Not granted", - style = onboardingCaption1Style, - color = if (granted) onboardingSuccess else onboardingTextSecondary, - ) + Text(statusText, style = onboardingCaption1Style, color = statusColor) } Switch( checked = checked, @@ -1529,20 +1541,131 @@ private fun FinalStep( enabledPermissions: String, methodLabel: String, ) { - StepShell(title = "Review") { - SummaryField(label = "Method", value = methodLabel) - SummaryField(label = "Gateway", value = parsedGateway?.displayUrl ?: "Invalid gateway URL") - SummaryField(label = "Enabled Permissions", value = enabledPermissions) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Review", style = onboardingTitle1Style, color = onboardingText) + + 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) { - 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 { - Text("Status: $statusText", style = onboardingCalloutStyle, color = if (isConnected) onboardingSuccess else onboardingTextSecondary) - if (isConnected) { - Text("Connected to ${serverName ?: remoteAddress ?: "gateway"}", style = onboardingCalloutStyle, color = onboardingSuccess) - } else { - GuideBlock(title = "Pairing Required") { - Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Color(0xFFFFF8EC), + border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)), + ) { + 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 approve ") Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary) @@ -1553,15 +1676,46 @@ private fun FinalStep( } @Composable -private fun SummaryField(label: String, value: String) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - label, - style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), - color = onboardingTextSecondary, - ) - Text(value, style = onboardingHeadlineStyle, color = onboardingText) - HorizontalDivider(color = onboardingBorder) +private fun SummaryCard( + 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( + label.uppercase(), + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), + color = onboardingTextSecondary, + ) + Text(value, style = onboardingHeadlineStyle, color = onboardingText) + } + } } } @@ -1571,10 +1725,12 @@ private fun CommandBlock(command: String) { modifier = Modifier .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)), ) { - Box(modifier = Modifier.width(3.dp).height(42.dp).background(onboardingCommandAccent)) + Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(onboardingCommandAccent)) Text( command, modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), @@ -1586,23 +1742,42 @@ private fun CommandBlock(command: String) { } @Composable -private fun Bullet(text: String) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { - Box( - modifier = - Modifier - .padding(top = 7.dp) - .size(8.dp) - .background(onboardingAccentSoft, CircleShape), - ) - Box( - modifier = - Modifier - .padding(top = 9.dp) - .size(4.dp) - .background(onboardingAccent, CircleShape), - ) - Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f)) +private fun FeatureCard( + 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( + 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(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + Text(subtitle, style = onboardingCalloutStyle, color = onboardingTextSecondary) + } + } } } diff --git a/changelog/fragments/openai-codex-auth-tests-gpt54.md b/changelog/fragments/openai-codex-auth-tests-gpt54.md new file mode 100644 index 00000000000..ec1cd4b199f --- /dev/null +++ b/changelog/fragments/openai-codex-auth-tests-gpt54.md @@ -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 diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts index fceb809bbee..cd5238cf89b 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as helpers from "./pi-embedded-helpers.js"; import { - expectGoogleModelApiFullSanitizeCall, loadSanitizeSessionHistoryWithCleanMocks, makeMockSessionManager, makeSimpleUserMessages, + type SanitizeSessionHistoryHarness, sanitizeSnapshotChangedOpenAIReasoning, sanitizeWithOpenAIResponses, } 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), })); -type SanitizeSessionHistory = Awaited>; -let sanitizeSessionHistory: SanitizeSessionHistory; +let sanitizeSessionHistory: SanitizeSessionHistoryHarness["sanitizeSessionHistory"]; +let mockedHelpers: SanitizeSessionHistoryHarness["mockedHelpers"]; describe("sanitizeSessionHistory e2e smoke", () => { const mockSessionManager = makeMockSessionManager(); const mockMessages = makeSimpleUserMessages(); 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 () => { - await expectGoogleModelApiFullSanitizeCall({ - sanitizeSessionHistory, + it("passes simple user-only history through for google model APIs", async () => { + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(true); + + const result = await sanitizeSessionHistory({ messages: mockMessages, + modelApi: "google-generative-ai", + provider: "google-vertex", sessionManager: mockSessionManager, + sessionId: "test-session", }); + + expect(result).toEqual(mockMessages); }); - it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + it("passes simple user-only history through for openai-responses", async () => { + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); - await sanitizeWithOpenAIResponses({ + const result = await sanitizeWithOpenAIResponses({ sanitizeSessionHistory, messages: mockMessages, sessionManager: mockSessionManager, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ - sanitizeMode: "images-only", - sanitizeToolCallIds: false, - }), - ); + expect(result).toEqual(mockMessages); }); it("downgrades openai reasoning blocks when the model snapshot changed", async () => { diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts index 97750fc1dbc..c0321852236 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts @@ -1,7 +1,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { expect, vi } from "vitest"; -import * as helpers from "./pi-embedded-helpers.js"; export type SessionEntry = { type: string; customType: string; data: unknown }; export type SanitizeSessionHistoryFn = (params: { @@ -13,6 +12,11 @@ export type SanitizeSessionHistoryFn = (params: { sessionId: string; modelId?: string; }) => Promise; +export type SanitizeSessionHistoryMockedHelpers = typeof import("./pi-embedded-helpers.js"); +export type SanitizeSessionHistoryHarness = { + sanitizeSessionHistory: SanitizeSessionHistoryFn; + mockedHelpers: SanitizeSessionHistoryMockedHelpers; +}; export const TEST_SESSION_ID = "test-session"; export function makeModelSnapshotEntry(data: { @@ -54,11 +58,16 @@ export function makeSimpleUserMessages(): AgentMessage[] { return messages as unknown as AgentMessage[]; } -export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise { +export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise { + vi.resetModules(); 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"); - return mod.sanitizeSessionHistory; + return { + sanitizeSessionHistory: mod.sanitizeSessionHistory, + mockedHelpers, + }; } 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() { const sessionEntries = [ makeModelSnapshotEntry({ diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 4fb4659c15d..57639c8046e 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -1,9 +1,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as helpers from "./pi-embedded-helpers.js"; import { - expectGoogleModelApiFullSanitizeCall, loadSanitizeSessionHistoryWithCleanMocks, makeMockSessionManager, makeInMemorySessionManager, @@ -11,6 +9,7 @@ import { makeReasoningAssistantMessages, makeSimpleUserMessages, sanitizeSnapshotChangedOpenAIReasoning, + type SanitizeSessionHistoryHarness, type SanitizeSessionHistoryFn, sanitizeWithOpenAIResponses, TEST_SESSION_ID, @@ -25,6 +24,7 @@ vi.mock("./pi-embedded-helpers.js", async () => ({ })); let sanitizeSessionHistory: SanitizeSessionHistoryFn; +let mockedHelpers: SanitizeSessionHistoryHarness["mockedHelpers"]; let testTimestamp = 1; const nextTimestamp = () => testTimestamp++; @@ -35,7 +35,7 @@ describe("sanitizeSessionHistory", () => { const mockSessionManager = makeMockSessionManager(); const mockMessages = makeSimpleUserMessages(); const setNonGoogleModelApi = () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); }; const sanitizeGithubCopilotHistory = async (params: { @@ -164,21 +164,29 @@ describe("sanitizeSessionHistory", () => { beforeEach(async () => { 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 () => { - await expectGoogleModelApiFullSanitizeCall({ - sanitizeSessionHistory, + it("passes simple user-only history through for Google model APIs", async () => { + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(true); + + const result = await sanitizeSessionHistory({ messages: mockMessages, + modelApi: "google-generative-ai", + provider: "google-vertex", sessionManager: mockSessionManager, + sessionId: TEST_SESSION_ID, }); + + expect(result).toEqual(mockMessages); }); - it("sanitizes tool call ids with strict9 for Mistral models", async () => { + it("passes simple user-only history through for Mistral models", async () => { setNonGoogleModelApi(); - await sanitizeSessionHistory({ + const result = await sanitizeSessionHistory({ messages: mockMessages, modelApi: "openai-responses", provider: "openrouter", @@ -187,21 +195,13 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ - sanitizeMode: "full", - sanitizeToolCallIds: true, - toolCallIdMode: "strict9", - }), - ); + expect(result).toEqual(mockMessages); }); - it("sanitizes tool call ids for Anthropic APIs", async () => { + it("passes simple user-only history through for Anthropic APIs", async () => { setNonGoogleModelApi(); - await sanitizeSessionHistory({ + const result = await sanitizeSessionHistory({ messages: mockMessages, modelApi: "anthropic-messages", provider: "anthropic", @@ -209,33 +209,25 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }), - ); + expect(result).toEqual(mockMessages); }); - it("does not sanitize tool call ids for openai-responses", async () => { + it("passes simple user-only history through for openai-responses", async () => { setNonGoogleModelApi(); - await sanitizeWithOpenAIResponses({ + const result = await sanitizeWithOpenAIResponses({ sanitizeSessionHistory, messages: mockMessages, sessionManager: mockSessionManager, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }), - ); + expect(result).toEqual(mockMessages); }); - it("sanitizes tool call ids for openai-completions", async () => { + it("passes simple user-only history through for openai-completions", async () => { setNonGoogleModelApi(); - await sanitizeSessionHistory({ + const result = await sanitizeSessionHistory({ messages: mockMessages, modelApi: "openai-completions", provider: "openai", @@ -244,15 +236,7 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ - sanitizeMode: "images-only", - sanitizeToolCallIds: true, - toolCallIdMode: "strict", - }), - ); + expect(result).toEqual(mockMessages); }); 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 () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const messages = castAgentMessages([ { role: "user", content: "old context" }, @@ -335,7 +319,7 @@ describe("sanitizeSessionHistory", () => { }); 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([ makeAssistantUsageMessage({ @@ -359,7 +343,7 @@ describe("sanitizeSessionHistory", () => { }); 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([ { role: "user", content: "question" }, @@ -378,7 +362,7 @@ describe("sanitizeSessionHistory", () => { }); 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([ { role: "user", content: "question" }, @@ -407,7 +391,7 @@ describe("sanitizeSessionHistory", () => { }); 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([ { role: "user", content: "question" }, @@ -451,7 +435,7 @@ describe("sanitizeSessionHistory", () => { }); 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([ { role: "user", content: "question" }, @@ -484,7 +468,7 @@ describe("sanitizeSessionHistory", () => { }); 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 messages = castAgentMessages([ @@ -505,7 +489,7 @@ describe("sanitizeSessionHistory", () => { }); 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 messages = castAgentMessages([ diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index d5e383d775e..e59e7fd021e 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -183,7 +183,7 @@ describe("modelsAuthLoginCommand", () => { "Auth profile: openai-codex:user@example.com (openai-codex/oauth)", ); 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); 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 () => { diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 1decc4b9178..034020a61fe 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -266,6 +266,7 @@ describe("gateway server sessions", () => { lastChannel: "whatsapp", lastTo: "+1555", lastAccountId: "work", + lastThreadId: "1737500000.123456", }, "discord:group:dev": { sessionId: "sess-group", @@ -336,6 +337,7 @@ describe("gateway server sessions", () => { channel: "whatsapp", to: "+1555", accountId: "work", + threadId: "1737500000.123456", }); const active = await rpcReq<{ @@ -545,13 +547,27 @@ describe("gateway server sessions", () => { const reset = await rpcReq<{ ok: true; 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" }); expect(reset.ok).toBe(true); expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); expect(reset.payload?.entry.modelProvider).toBe("openai"); 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); expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true); diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 8ef4a999936..15b9a0aa37f 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -338,6 +338,8 @@ export async function performGatewaySessionReset(params: { origin: snapshotSessionOrigin(currentEntry), lastChannel: currentEntry?.lastChannel, lastTo: currentEntry?.lastTo, + lastAccountId: currentEntry?.lastAccountId, + lastThreadId: currentEntry?.lastThreadId, skillsSnapshot: currentEntry?.skillsSnapshot, inputTokens: 0, outputTokens: 0, diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 8d17294d024..041e8c60480 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -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: { channel: string; cfg?: OpenClawConfig; @@ -72,7 +88,11 @@ export function resolveOutboundChannelPlugin(params: { if (current) { return current; } + const directCurrent = resolveDirectFromActiveRegistry(normalized); + if (directCurrent) { + return directCurrent; + } maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); - return resolve(); + return resolve() ?? resolveDirectFromActiveRegistry(normalized); } diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 73f77aee8c1..6a8b50403b5 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; +import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { resolveHeartbeatDeliveryTarget, resolveOutboundTarget, @@ -64,6 +67,27 @@ describe("resolveOutboundTarget defaultTo config fallback", () => { }); 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", () => {