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.
- 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

View File

@ -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)
Text(
"Run `openclaw qr` on your gateway host, then scan the code with this device.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
CommandBlock("openclaw qr")
Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
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,14 +1307,10 @@ 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)) {
Column(modifier = Modifier.padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = onboardingTitle1Style, color = onboardingText)
content()
}
HorizontalDivider(color = onboardingBorder)
}
}
@Composable
@ -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 <requestId>")
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)) {
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,
style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
label.uppercase(),
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp),
color = onboardingTextSecondary,
)
Text(value, style = onboardingHeadlineStyle, color = onboardingText)
HorizontalDivider(color = onboardingBorder)
}
}
}
}
@ -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) {
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
.padding(top = 7.dp)
.size(8.dp)
.background(onboardingAccentSoft, CircleShape),
.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),
)
Box(
modifier =
Modifier
.padding(top = 9.dp)
.size(4.dp)
.background(onboardingAccent, CircleShape),
)
Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = onboardingHeadlineStyle, color = onboardingText)
Text(subtitle, style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}
}
}

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 * 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<ReturnType<typeof loadSanitizeSessionHistoryWithCleanMocks>>;
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",
});
it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
expect(result).toEqual(mockMessages);
});
await sanitizeWithOpenAIResponses({
it("passes simple user-only history through for openai-responses", async () => {
vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false);
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 () => {

View File

@ -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<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 function makeModelSnapshotEntry(data: {
@ -54,11 +58,16 @@ export function makeSimpleUserMessages(): AgentMessage[] {
return messages as unknown as AgentMessage[];
}
export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise<SanitizeSessionHistoryFn> {
export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise<SanitizeSessionHistoryHarness> {
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({

View File

@ -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,
});
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();
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([

View File

@ -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 () => {

View File

@ -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);

View File

@ -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,

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: {
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);
}

View File

@ -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", () => {