Compare commits

...

11 Commits

Author SHA1 Message Date
Br1an 59664d30d1
Merge b32201979b into c4265a5f16 2026-03-15 14:38:41 +00:00
Br1an67 b32201979b fix(compaction): content-aware isRealConversationMessage skips heartbeat/empty messages
The compaction safeguard counted all user/assistant messages as real,
including HEARTBEAT_OK and NO_REPLY boilerplate. This caused premature
compaction. Now checks for meaningful text content.

Closes #40727

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-15 22:38:33 +08:00
Ayaan Zaidi c4265a5f16
fix: preserve Telegram word boundaries when rechunking HTML (#47274)
* fix: preserve Telegram chunk word boundaries

* fix: address Telegram chunking review feedback

* fix: preserve Telegram retry separators

* fix: preserve Telegram chunking boundaries (#47274)
2026-03-15 18:10:49 +05:30
Andrew Demczuk 26e0a3ee9a
fix(gateway): skip Control UI pairing when auth.mode=none (closes #42931) (#47148)
When auth is completely disabled (mode=none), requiring device pairing
for Control UI operator sessions adds friction without security value
since any client can already connect without credentials.

Add authMode parameter to shouldSkipControlUiPairing so the bypass
fires only for Control UI + operator role + auth.mode=none. This avoids
the #43478 regression where a top-level OR disabled pairing for ALL
websocket clients.
2026-03-15 13:03:39 +01:00
助爪 5c5c64b612
Deduplicate repeated tool call IDs for OpenAI-compatible APIs (#40996)
Merged via squash.

Prepared head SHA: 38d8048359
Co-authored-by: xaeon2026 <264572156+xaeon2026@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-15 19:46:07 +08:00
Jason 9d3e653ec9
fix(web): handle 515 Stream Error during WhatsApp QR pairing (#27910)
* fix(web): handle 515 Stream Error during WhatsApp QR pairing

getStatusCode() never unwrapped the lastDisconnect wrapper object,
so login.errorStatus was always undefined and the 515 restart path
in restartLoginSocket was dead code.

- Add err.error?.output?.statusCode fallback to getStatusCode()
- Export waitForCredsSaveQueue() so callers can await pending creds
- Await creds flush in restartLoginSocket before creating new socket

Fixes #3942

* test: update session mock for getStatusCode unwrap + waitForCredsSaveQueue

Mirror the getStatusCode fix (err.error?.output?.statusCode fallback)
in the test mock and export waitForCredsSaveQueue so restartLoginSocket
tests work correctly.

* fix(web): scope creds save queue per-authDir to avoid cross-account blocking

The credential save queue was a single global promise chain shared by all
WhatsApp accounts. In multi-account setups, a slow save on one account
blocked credential writes and 515 restart recovery for unrelated accounts.

Replace the global queue with a per-authDir Map so each account's creds
serialize independently. waitForCredsSaveQueue() now accepts an optional
authDir to wait on a single account's queue, or waits on all when omitted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: use real Baileys v7 error shape in 515 restart test

The test was using { output: { statusCode: 515 } } which was already
handled before the fix. Updated to use the actual Baileys v7 shape
{ error: { output: { statusCode: 515 } } } to cover the new fallback
path in getStatusCode.

Co-Authored-By: Claude Code (Opus 4.6) <noreply@anthropic.com>

* fix(web): bound credential-queue wait during 515 restart

Prevents restartLoginSocket from blocking indefinitely if a queued
saveCreds() promise stalls (e.g. hung filesystem write).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clear flush timeout handle and assert creds queue in test

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: evict settled credsSaveQueues entries to prevent unbounded growth

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: share WhatsApp 515 creds flush handling (#27910) (thanks @asyncjason)

---------

Co-authored-by: Jason Separovic <jason@wilma.dog>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 17:00:07 +05:30
Ted Li 843e3c1efb
fix(whatsapp): restore append recency filter lost in extensions refactor, handle Long timestamps (#42588)
Merged via squash.

Prepared head SHA: 8ce59bb715
Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com>
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Reviewed-by: @scoootscooob
2026-03-15 03:03:31 -07:00
Ace Lee d7ac16788e
fix(android): support android node `calllog.search` (#44073)
* fix(android): support android node  `calllog.search`

* fix(android): support android node calllog.search

* fix(android): wire callLog through shared surfaces

* fix: land Android callLog support (#44073) (thanks @lxk7280)

---------

Co-authored-by: lixuankai <lixuankai@oppo.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 14:54:32 +05:30
Frank Yang 4bb8a65edd
fix: forward forceDocument through sendPayload path (follow-up to #45111) (#47119)
Merged via squash.

Prepared head SHA: d791190f83
Co-authored-by: thepagent <262003297+thepagent@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-15 17:23:53 +08:00
Sahan 9616d1e8ba
fix: Disable strict mode tools for non-native openai-completions compatible APIs (#45497)
Merged via squash.

Prepared head SHA: 20fe05fe74
Co-authored-by: sahancava <57447079+sahancava@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-15 16:36:52 +08:00
Onur Solmaz a2d73be3a4
Docs: switch README logo to SVG assets (#47049) 2026-03-15 08:58:45 +01:00
49 changed files with 2648 additions and 90 deletions

View File

@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
### Fixes
@ -28,6 +29,10 @@ Docs: https://docs.openclaw.ai
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. Thanks @vincentkoc.
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
### Fixes
@ -38,6 +43,8 @@ Docs: https://docs.openclaw.ai
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
## 2026.3.13

View File

@ -2,8 +2,8 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.svg">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.svg" alt="OpenClaw" width="500">
</picture>
</p>

View File

@ -19,6 +19,7 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />

View File

@ -110,6 +110,10 @@ class NodeRuntime(context: Context) {
appContext = appContext,
)
private val callLogHandler: CallLogHandler = CallLogHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler = MotionHandler(
appContext = appContext,
)
@ -151,6 +155,7 @@ class NodeRuntime(context: Context) {
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
callLogHandler = callLogHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },

View File

@ -0,0 +1,247 @@
package ai.openclaw.app.node
import android.Manifest
import android.content.Context
import android.provider.CallLog
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.put
private const val DEFAULT_CALL_LOG_LIMIT = 25
internal data class CallLogRecord(
val number: String?,
val cachedName: String?,
val date: Long,
val duration: Long,
val type: Int,
)
internal data class CallLogSearchRequest(
val limit: Int, // Number of records to return
val offset: Int, // Offset value
val cachedName: String?, // Search by contact name
val number: String?, // Search by phone number
val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd)
val dateStart: Long?, // Query start time (timestamp)
val dateEnd: Long?, // Query end time (timestamp)
val duration: Long?, // Search by duration (seconds)
val type: Int?, // Search by call log type
)
internal interface CallLogDataSource {
fun hasReadPermission(context: Context): Boolean
fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord>
}
private object SystemCallLogDataSource : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CALL_LOG
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
val resolver = context.contentResolver
val projection = arrayOf(
CallLog.Calls.NUMBER,
CallLog.Calls.CACHED_NAME,
CallLog.Calls.DATE,
CallLog.Calls.DURATION,
CallLog.Calls.TYPE,
)
// Build selection and selectionArgs for filtering
val selections = mutableListOf<String>()
val selectionArgs = mutableListOf<String>()
request.cachedName?.let {
selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
selectionArgs.add("%$it%")
}
request.number?.let {
selections.add("${CallLog.Calls.NUMBER} LIKE ?")
selectionArgs.add("%$it%")
}
// Support time range query
if (request.dateStart != null && request.dateEnd != null) {
selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?")
selectionArgs.add(request.dateStart.toString())
selectionArgs.add(request.dateEnd.toString())
} else if (request.dateStart != null) {
selections.add("${CallLog.Calls.DATE} >= ?")
selectionArgs.add(request.dateStart.toString())
} else if (request.dateEnd != null) {
selections.add("${CallLog.Calls.DATE} <= ?")
selectionArgs.add(request.dateEnd.toString())
} else if (request.date != null) {
// Compatible with the old date parameter (exact match)
selections.add("${CallLog.Calls.DATE} = ?")
selectionArgs.add(request.date.toString())
}
request.duration?.let {
selections.add("${CallLog.Calls.DURATION} = ?")
selectionArgs.add(it.toString())
}
request.type?.let {
selections.add("${CallLog.Calls.TYPE} = ?")
selectionArgs.add(it.toString())
}
val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null
val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null
val sortOrder = "${CallLog.Calls.DATE} DESC"
resolver.query(
CallLog.Calls.CONTENT_URI,
projection,
selection,
selectionArgsArray,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
// Skip offset rows
if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
// Successfully moved to offset position
}
val out = mutableListOf<CallLogRecord>()
var count = 0
while (cursor.moveToNext() && count < request.limit) {
out += CallLogRecord(
number = cursor.getString(numberIndex),
cachedName = cursor.getString(cachedNameIndex),
date = cursor.getLong(dateIndex),
duration = cursor.getLong(durationIndex),
type = cursor.getInt(typeIndex),
)
count++
}
return out
}
}
}
class CallLogHandler private constructor(
private val appContext: Context,
private val dataSource: CallLogDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource)
fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALL_LOG_PERMISSION_REQUIRED",
message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission",
)
}
val request = parseSearchRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val callLogs = dataSource.search(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put(
"callLogs",
buildJsonArray {
callLogs.forEach { add(callLogJson(it)) }
},
)
}.toString(),
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALL_LOG_UNAVAILABLE",
message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}",
)
}
}
private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? {
if (paramsJson.isNullOrBlank()) {
return CallLogSearchRequest(
limit = DEFAULT_CALL_LOG_LIMIT,
offset = 0,
cachedName = null,
number = null,
date = null,
dateStart = null,
dateEnd = null,
duration = null,
type = null,
)
}
val params = try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
.coerceIn(1, 200)
val offset = ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull()
val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull()
val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull()
val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull()
val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull()
return CallLogSearchRequest(
limit = limit,
offset = offset,
cachedName = cachedName,
number = number,
date = date,
dateStart = dateStart,
dateEnd = dateEnd,
duration = duration,
type = type,
)
}
private fun callLogJson(callLog: CallLogRecord): JsonObject {
return buildJsonObject {
put("number", JsonPrimitive(callLog.number))
put("cachedName", JsonPrimitive(callLog.cachedName))
put("date", JsonPrimitive(callLog.date))
put("duration", JsonPrimitive(callLog.duration))
put("type", JsonPrimitive(callLog.type))
}
}
companion object {
internal fun forTesting(
appContext: Context,
dataSource: CallLogDataSource,
): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource)
}
}

View File

@ -212,6 +212,13 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
put(
"callLog",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
promptableWhenDenied = true,
),
)
put(
"motion",
permissionStateJson(

View File

@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@ -84,6 +85,7 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Motion.rawValue,
availability = NodeCapabilityAvailability.MotionAvailable,
),
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
)
val all: List<InvokeCommandSpec> =
@ -187,6 +189,9 @@ object InvokeCommandRegistry {
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
),
InvokeCommandSpec(
name = "debug.logs",
availability = InvokeCommandAvailability.DebugBuild,

View File

@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@ -27,6 +28,7 @@ class InvokeDispatcher(
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler,
private val callLogHandler: CallLogHandler,
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
@ -161,6 +163,9 @@ class InvokeDispatcher(
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
// CallLog command
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
// Debug commands
"debug.ed25519" -> debugHandler.handleEd25519()
"debug.logs" -> debugHandler.handleLogs()

View File

@ -13,6 +13,7 @@ enum class OpenClawCapability(val rawValue: String) {
Contacts("contacts"),
Calendar("calendar"),
Motion("motion"),
CallLog("callLog"),
}
enum class OpenClawCanvasCommand(val rawValue: String) {
@ -137,3 +138,12 @@ enum class OpenClawMotionCommand(val rawValue: String) {
const val NamespacePrefix: String = "motion."
}
}
enum class OpenClawCallLogCommand(val rawValue: String) {
Search("callLog.search"),
;
companion object {
const val NamespacePrefix: String = "callLog."
}
}

View File

@ -121,6 +121,7 @@ private enum class PermissionToggle {
Calendar,
Motion,
Sms,
CallLog,
}
private enum class SpecialAccessToggle {
@ -288,6 +289,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
}
var enableCallLog by
rememberSaveable {
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
}
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
@ -304,6 +309,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
PermissionToggle.Calendar -> enableCalendar = enabled
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
PermissionToggle.CallLog -> enableCallLog = enabled
}
}
@ -331,6 +337,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
}
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
@ -352,6 +359,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableCalendar,
enableMotion,
enableSms,
enableCallLog,
smsAvailable,
motionAvailable,
) {
@ -367,6 +375,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableCalendar) enabled += "Calendar"
if (enableMotion && motionAvailable) enabled += "Motion"
if (smsAvailable && enableSms) enabled += "SMS"
if (enableCallLog) enabled += "Call Log"
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
}
@ -595,6 +604,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
motionPermissionRequired = motionPermissionRequired,
enableSms = enableSms,
smsAvailable = smsAvailable,
enableCallLog = enableCallLog,
context = context,
onDiscoveryChange = { checked ->
requestPermissionToggle(
@ -692,6 +702,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
)
}
},
onCallLogChange = { checked ->
requestPermissionToggle(
PermissionToggle.CallLog,
checked,
listOf(Manifest.permission.READ_CALL_LOG),
)
},
)
OnboardingStep.FinalCheck ->
FinalStep(
@ -1282,6 +1299,7 @@ private fun PermissionsStep(
motionPermissionRequired: Boolean,
enableSms: Boolean,
smsAvailable: Boolean,
enableCallLog: Boolean,
context: Context,
onDiscoveryChange: (Boolean) -> Unit,
onLocationChange: (Boolean) -> Unit,
@ -1294,6 +1312,7 @@ private fun PermissionsStep(
onCalendarChange: (Boolean) -> Unit,
onMotionChange: (Boolean) -> Unit,
onSmsChange: (Boolean) -> Unit,
onCallLogChange: (Boolean) -> Unit,
) {
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
val locationGranted =
@ -1424,6 +1443,15 @@ private fun PermissionsStep(
onCheckedChange = onSmsChange,
)
}
InlineDivider()
PermissionToggleRow(
title = "Call Log",
subtitle = "callLog.search",
checked = enableCallLog,
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
onCheckedChange = onCallLogChange,
)
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}

View File

@ -218,6 +218,18 @@ fun SettingsSheet(viewModel: MainViewModel) {
calendarPermissionGranted = readOk && writeOk
}
var callLogPermissionGranted by
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
PackageManager.PERMISSION_GRANTED,
)
}
val callLogPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
callLogPermissionGranted = granted
}
var motionPermissionGranted by
remember {
mutableStateOf(
@ -266,6 +278,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
callLogPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
PackageManager.PERMISSION_GRANTED
motionPermissionGranted =
!motionPermissionRequired ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
@ -601,6 +616,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Call Log", style = mobileHeadline) },
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (callLogPermissionGranted) {
openAppSettings(context)
} else {
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (callLogPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
if (motionAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(

View File

@ -0,0 +1,193 @@
package ai.openclaw.app.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CallLogHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleCallLogSearch_requiresPermission() {
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = false))
val result = handler.handleCallLogSearch(null)
assertFalse(result.ok)
assertEquals("CALL_LOG_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleCallLogSearch_rejectsInvalidJson() {
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = true))
val result = handler.handleCallLogSearch("invalid json")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleCallLogSearch_returnsCallLogs() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
assertEquals(1709280000000L, callLogs.first().jsonObject.getValue("date").jsonPrimitive.content.toLong())
assertEquals(60L, callLogs.first().jsonObject.getValue("duration").jsonPrimitive.content.toLong())
assertEquals(1, callLogs.first().jsonObject.getValue("type").jsonPrimitive.content.toInt())
}
@Test
fun handleCallLogSearch_withFilters() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 120L,
type = 2,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch(
"""{"number":"123456","cachedName":"lixuankai","dateStart":1709270000000,"dateEnd":1709290000000,"duration":120,"type":2}"""
)
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withPagination() {
val callLogs =
listOf(
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
),
CallLogRecord(
number = "+654321",
cachedName = "lixuankai2",
date = 1709280001000L,
duration = 120L,
type = 2,
),
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = callLogs),
)
val result = handler.handleCallLogSearch("""{"limit":1,"offset":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogsResult = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogsResult.size)
assertEquals("lixuankai2", callLogsResult.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withDefaultParams() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch(null)
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withNullFields() {
val callLog =
CallLogRecord(
number = null,
cachedName = null,
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
// Verify null values are properly serialized
val callLogObj = callLogs.first().jsonObject
assertTrue(callLogObj.containsKey("number"))
assertTrue(callLogObj.containsKey("cachedName"))
}
}
private class FakeCallLogDataSource(
private val canRead: Boolean,
private val searchResults: List<CallLogRecord> = emptyList(),
) : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean = canRead
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
val startIndex = request.offset.coerceAtLeast(0)
val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size)
return if (startIndex < searchResults.size) {
searchResults.subList(startIndex, endIndex)
} else {
emptyList()
}
}
}

View File

@ -93,6 +93,7 @@ class DeviceHandlerTest {
"photos",
"contacts",
"calendar",
"callLog",
"motion",
)
for (key in expected) {

View File

@ -2,6 +2,7 @@ package ai.openclaw.app.node
import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
@ -25,6 +26,7 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
OpenClawCapability.CallLog.rawValue,
)
private val optionalCapabilities =
@ -50,6 +52,7 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
OpenClawCallLogCommand.Search.rawValue,
)
private val optionalCommands =

View File

@ -34,6 +34,7 @@ class OpenClawProtocolConstantsTest {
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
assertEquals("motion", OpenClawCapability.Motion.rawValue)
assertEquals("callLog", OpenClawCapability.CallLog.rawValue)
}
@Test
@ -84,4 +85,9 @@ class OpenClawProtocolConstantsTest {
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
}
@Test
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -285,6 +285,7 @@ Available families:
- `photos.latest`
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
- `motion.activity`, `motion.pedometer`
Example invokes:

View File

@ -163,4 +163,5 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- `photos.latest`
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
- `motion.activity`, `motion.pedometer`

View File

@ -403,3 +403,30 @@ describe("telegramPlugin duplicate token guard", () => {
);
});
});
describe("telegramPlugin outbound sendPayload forceDocument", () => {
it("forwards forceDocument to the underlying send call when channelData is present", async () => {
const sendMessageTelegram = installSendMessageRuntime(
vi.fn(async () => ({ messageId: "tg-fd" })),
);
await telegramPlugin.outbound!.sendPayload!({
cfg: createCfg(),
to: "12345",
text: "",
payload: {
text: "here is an image",
mediaUrls: ["https://example.com/photo.png"],
channelData: { telegram: {} },
},
accountId: "ops",
forceDocument: true,
});
expect(sendMessageTelegram).toHaveBeenCalledWith(
"12345",
expect.any(String),
expect.objectContaining({ forceDocument: true }),
);
});
});

View File

@ -96,6 +96,7 @@ function buildTelegramSendOptions(params: {
replyToId?: string | null;
threadId?: string | number | null;
silent?: boolean | null;
forceDocument?: boolean | null;
}): TelegramSendOptions {
return {
verbose: false,
@ -106,6 +107,7 @@ function buildTelegramSendOptions(params: {
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
accountId: params.accountId ?? undefined,
silent: params.silent ?? undefined,
forceDocument: params.forceDocument ?? undefined,
};
}
@ -386,6 +388,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
replyToId,
threadId,
silent,
forceDocument,
}) => {
const send =
resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ??
@ -401,6 +404,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
replyToId,
threadId,
silent,
forceDocument,
}),
});
return { channel: "telegram", ...result };

View File

@ -512,6 +512,146 @@ function sliceLinkSpans(
});
}
function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): MarkdownIR {
return {
text: ir.text.slice(start, end),
styles: sliceStyleSpans(ir.styles, start, end),
links: sliceLinkSpans(ir.links, start, end),
};
}
function mergeAdjacentStyleSpans(styles: MarkdownIR["styles"]): MarkdownIR["styles"] {
const merged: MarkdownIR["styles"] = [];
for (const span of styles) {
const last = merged.at(-1);
if (last && last.style === span.style && span.start <= last.end) {
last.end = Math.max(last.end, span.end);
continue;
}
merged.push({ ...span });
}
return merged;
}
function mergeAdjacentLinkSpans(links: MarkdownIR["links"]): MarkdownIR["links"] {
const merged: MarkdownIR["links"] = [];
for (const link of links) {
const last = merged.at(-1);
if (last && last.href === link.href && link.start <= last.end) {
last.end = Math.max(last.end, link.end);
continue;
}
merged.push({ ...link });
}
return merged;
}
function mergeMarkdownIRChunks(left: MarkdownIR, right: MarkdownIR): MarkdownIR {
const offset = left.text.length;
return {
text: left.text + right.text,
styles: mergeAdjacentStyleSpans([
...left.styles,
...right.styles.map((span) => ({
...span,
start: span.start + offset,
end: span.end + offset,
})),
]),
links: mergeAdjacentLinkSpans([
...left.links,
...right.links.map((link) => ({
...link,
start: link.start + offset,
end: link.end + offset,
})),
]),
};
}
function renderTelegramChunkHtml(ir: MarkdownIR): string {
return wrapFileReferencesInHtml(renderTelegramHtml(ir));
}
function findMarkdownIRPreservedSplitIndex(text: string, start: number, limit: number): number {
const maxEnd = Math.min(text.length, start + limit);
if (maxEnd >= text.length) {
return text.length;
}
let lastOutsideParenNewlineBreak = -1;
let lastOutsideParenWhitespaceBreak = -1;
let lastOutsideParenWhitespaceRunStart = -1;
let lastAnyNewlineBreak = -1;
let lastAnyWhitespaceBreak = -1;
let lastAnyWhitespaceRunStart = -1;
let parenDepth = 0;
let sawNonWhitespace = false;
for (let index = start; index < maxEnd; index += 1) {
const char = text[index];
if (char === "(") {
sawNonWhitespace = true;
parenDepth += 1;
continue;
}
if (char === ")" && parenDepth > 0) {
sawNonWhitespace = true;
parenDepth -= 1;
continue;
}
if (!/\s/.test(char)) {
sawNonWhitespace = true;
continue;
}
if (!sawNonWhitespace) {
continue;
}
if (char === "\n") {
lastAnyNewlineBreak = index + 1;
if (parenDepth === 0) {
lastOutsideParenNewlineBreak = index + 1;
}
continue;
}
const whitespaceRunStart =
index === start || !/\s/.test(text[index - 1] ?? "") ? index : lastAnyWhitespaceRunStart;
lastAnyWhitespaceBreak = index + 1;
lastAnyWhitespaceRunStart = whitespaceRunStart;
if (parenDepth === 0) {
lastOutsideParenWhitespaceBreak = index + 1;
lastOutsideParenWhitespaceRunStart = whitespaceRunStart;
}
}
const resolveWhitespaceBreak = (breakIndex: number, runStart: number): number => {
if (breakIndex <= start) {
return breakIndex;
}
if (runStart <= start) {
return breakIndex;
}
return /\s/.test(text[breakIndex] ?? "") ? runStart : breakIndex;
};
if (lastOutsideParenNewlineBreak > start) {
return lastOutsideParenNewlineBreak;
}
if (lastOutsideParenWhitespaceBreak > start) {
return resolveWhitespaceBreak(
lastOutsideParenWhitespaceBreak,
lastOutsideParenWhitespaceRunStart,
);
}
if (lastAnyNewlineBreak > start) {
return lastAnyNewlineBreak;
}
if (lastAnyWhitespaceBreak > start) {
return resolveWhitespaceBreak(lastAnyWhitespaceBreak, lastAnyWhitespaceRunStart);
}
return maxEnd;
}
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) {
return [];
@ -523,7 +663,7 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
const chunks: MarkdownIR[] = [];
let cursor = 0;
while (cursor < ir.text.length) {
const end = Math.min(ir.text.length, cursor + normalizedLimit);
const end = findMarkdownIRPreservedSplitIndex(ir.text, cursor, normalizedLimit);
chunks.push({
text: ir.text.slice(cursor, end),
styles: sliceStyleSpans(ir.styles, cursor, end),
@ -534,32 +674,98 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
return chunks;
}
function coalesceWhitespaceOnlyMarkdownIRChunks(chunks: MarkdownIR[], limit: number): MarkdownIR[] {
const coalesced: MarkdownIR[] = [];
let index = 0;
while (index < chunks.length) {
const chunk = chunks[index];
if (!chunk) {
index += 1;
continue;
}
if (chunk.text.trim().length > 0) {
coalesced.push(chunk);
index += 1;
continue;
}
const prev = coalesced.at(-1);
const next = chunks[index + 1];
const chunkLength = chunk.text.length;
const canMergePrev = (candidate: MarkdownIR) =>
renderTelegramChunkHtml(candidate).length <= limit;
const canMergeNext = (candidate: MarkdownIR) =>
renderTelegramChunkHtml(candidate).length <= limit;
if (prev) {
const mergedPrev = mergeMarkdownIRChunks(prev, chunk);
if (canMergePrev(mergedPrev)) {
coalesced[coalesced.length - 1] = mergedPrev;
index += 1;
continue;
}
}
if (next) {
const mergedNext = mergeMarkdownIRChunks(chunk, next);
if (canMergeNext(mergedNext)) {
chunks[index + 1] = mergedNext;
index += 1;
continue;
}
}
if (prev && next) {
for (let prefixLength = chunkLength - 1; prefixLength >= 1; prefixLength -= 1) {
const prefix = sliceMarkdownIR(chunk, 0, prefixLength);
const suffix = sliceMarkdownIR(chunk, prefixLength, chunkLength);
const mergedPrev = mergeMarkdownIRChunks(prev, prefix);
const mergedNext = mergeMarkdownIRChunks(suffix, next);
if (canMergePrev(mergedPrev) && canMergeNext(mergedNext)) {
coalesced[coalesced.length - 1] = mergedPrev;
chunks[index + 1] = mergedNext;
break;
}
}
}
index += 1;
}
return coalesced;
}
function renderTelegramChunksWithinHtmlLimit(
ir: MarkdownIR,
limit: number,
): TelegramFormattedChunk[] {
const normalizedLimit = Math.max(1, Math.floor(limit));
const pending = chunkMarkdownIR(ir, normalizedLimit);
const rendered: TelegramFormattedChunk[] = [];
const finalized: MarkdownIR[] = [];
while (pending.length > 0) {
const chunk = pending.shift();
if (!chunk) {
continue;
}
const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk));
const html = renderTelegramChunkHtml(chunk);
if (html.length <= normalizedLimit || chunk.text.length <= 1) {
rendered.push({ html, text: chunk.text });
finalized.push(chunk);
continue;
}
const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length);
if (split.length <= 1) {
// Worst-case safety: avoid retry loops, deliver the chunk as-is.
rendered.push({ html, text: chunk.text });
finalized.push(chunk);
continue;
}
pending.unshift(...split);
}
return rendered;
return coalesceWhitespaceOnlyMarkdownIRChunks(finalized, normalizedLimit).map((chunk) => ({
html: renderTelegramChunkHtml(chunk),
text: chunk.text,
}));
}
export function markdownToTelegramChunks(

View File

@ -174,6 +174,35 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true);
});
it("prefers word boundaries when html-limit retry splits formatted prose", () => {
const input = "**Which of these**";
const chunks = markdownToTelegramChunks(input, 16);
expect(chunks.map((chunk) => chunk.text)).toEqual(["Which of ", "these"]);
expect(chunks.every((chunk) => chunk.html.length <= 16)).toBe(true);
});
it("falls back to in-paren word boundaries when the parenthesis is unbalanced", () => {
const input = "**foo (bar baz qux quux**";
const chunks = markdownToTelegramChunks(input, 20);
expect(chunks.map((chunk) => chunk.text)).toEqual(["foo", "(bar baz qux ", "quux"]);
expect(chunks.every((chunk) => chunk.html.length <= 20)).toBe(true);
});
it("does not emit whitespace-only chunks during html-limit retry splitting", () => {
const input = "**ab <<**";
const chunks = markdownToTelegramChunks(input, 11);
expect(chunks.map((chunk) => chunk.text).join("")).toBe("ab <<");
expect(chunks.every((chunk) => chunk.text.trim().length > 0)).toBe(true);
expect(chunks.every((chunk) => chunk.html.length <= 11)).toBe(true);
});
it("preserves paragraph separators when retry chunking produces whitespace-only spans", () => {
const input = "ab\n\n<<";
const chunks = markdownToTelegramChunks(input, 6);
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 6)).toBe(true);
});
});
describe("edge cases", () => {

View File

@ -141,6 +141,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
deps,
replyToId,
threadId,
forceDocument,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
@ -156,6 +157,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
baseOpts: {
...baseOpts,
mediaLocalRoots,
forceDocument: forceDocument ?? false,
},
});
return { channel: "telegram", ...result };

View File

@ -413,8 +413,14 @@ export async function monitorWebInbox(options: {
// If this is history/offline catch-up, mark read above but skip auto-reply.
if (upsert.type === "append") {
const APPEND_RECENT_GRACE_MS = 60_000;
const msgTsRaw = msg.messageTimestamp;
const msgTsNum = msgTsRaw != null ? Number(msgTsRaw) : NaN;
const msgTsMs = Number.isFinite(msgTsNum) ? msgTsNum * 1000 : 0;
if (msgTsMs < connectedAtMs - APPEND_RECENT_GRACE_MS) {
continue;
}
}
const enriched = await enrichInboundMessage(msg);
if (!enriched) {

View File

@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
import { createWaSocket, logoutWeb, waitForWaConnection } from "./session.js";
import {
createWaSocket,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
vi.mock("./session.js", () => {
const createWaSocket = vi.fn(
@ -17,11 +22,13 @@ vi.mock("./session.js", () => {
const getStatusCode = vi.fn(
(err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status,
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
);
const webAuthExists = vi.fn(async () => false);
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null }));
const logoutWeb = vi.fn(async () => true);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
createWaSocket,
waitForWaConnection,
@ -30,6 +37,7 @@ vi.mock("./session.js", () => {
webAuthExists,
readWebSelfId,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
};
});
@ -39,22 +47,43 @@ vi.mock("./qr-image.js", () => ({
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const logoutWebMock = vi.mocked(logoutWeb);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("login-qr", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("restarts login once on status 515 and completes", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 515 } })
// Baileys v7 wraps the error: { error: BoomError(515) }
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
const result = await waitForWebLogin({ timeoutMs: 5000 });
const resultPromise = waitForWebLogin({ timeoutMs: 5000 });
await flushTasks();
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String));
releaseCredsFlush?.();
const result = await resultPromise;
expect(result.connected).toBe(true);
expect(createWaSocketMock).toHaveBeenCalledTimes(2);

View File

@ -12,6 +12,7 @@ import {
getStatusCode,
logoutWeb,
readWebSelfId,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
webAuthExists,
} from "./session.js";
@ -85,9 +86,10 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) {
}
login.restartAttempted = true;
runtime.log(
info("WhatsApp asked for a restart after pairing (code 515); retrying connection once…"),
info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
);
closeSocket(login.sock);
await waitForCredsSaveQueueWithTimeout(login.authDir);
try {
const sock = await createWaSocket(false, login.verbose, {
authDir: login.authDir,

View File

@ -4,7 +4,12 @@ import path from "node:path";
import { DisconnectReason } from "@whiskeysockets/baileys";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loginWeb } from "./login.js";
import { createWaSocket, formatError, waitForWaConnection } from "./session.js";
import {
createWaSocket,
formatError,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
const rmMock = vi.spyOn(fs, "rm");
@ -35,10 +40,19 @@ vi.mock("./session.js", () => {
const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB));
const waitForWaConnection = vi.fn();
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
const getStatusCode = vi.fn(
(err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return {
createWaSocket,
waitForWaConnection,
formatError,
getStatusCode,
waitForCredsSaveQueueWithTimeout,
WA_WEB_AUTH_DIR: authDir,
logoutWeb: vi.fn(async (params: { authDir?: string }) => {
await fs.rm(params.authDir ?? authDir, {
@ -52,8 +66,14 @@ vi.mock("./session.js", () => {
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const formatErrorMock = vi.mocked(formatError);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("loginWeb coverage", () => {
beforeEach(() => {
vi.useFakeTimers();
@ -65,12 +85,25 @@ describe("loginWeb coverage", () => {
});
it("restarts once when WhatsApp requests code 515", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 515 } })
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const runtime = { log: vi.fn(), error: vi.fn() } as never;
await loginWeb(false, waitForWaConnectionMock as never, runtime);
const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(authDir);
releaseCredsFlush?.();
await pendingLogin;
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
const firstSock = await createWaSocketMock.mock.results[0]?.value;

View File

@ -5,7 +5,14 @@ import { danger, info, success } from "../../../src/globals.js";
import { logInfo } from "../../../src/logger.js";
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import { createWaSocket, formatError, logoutWeb, waitForWaConnection } from "./session.js";
import {
createWaSocket,
formatError,
getStatusCode,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
export async function loginWeb(
verbose: boolean,
@ -24,20 +31,17 @@ export async function loginWeb(
await wait(sock);
console.log(success("✅ Linked! Credentials saved for future sends."));
} catch (err) {
const code =
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ??
(err as { output?: { statusCode?: number } })?.output?.statusCode;
const code = getStatusCode(err);
if (code === 515) {
console.log(
info(
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
),
info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
);
try {
sock.ws?.close();
} catch {
// ignore
}
await waitForCredsSaveQueueWithTimeout(account.authDir);
const retry = await createWaSocket(false, verbose, {
authDir: account.authDir,
});

View File

@ -0,0 +1,149 @@
import "./monitor-inbox.test-harness.js";
import { describe, expect, it, vi } from "vitest";
import { monitorWebInbox } from "./inbound.js";
import {
DEFAULT_ACCOUNT_ID,
getAuthDir,
getSock,
installWebMonitorInboxUnitTestHooks,
} from "./monitor-inbox.test-harness.js";
describe("append upsert handling (#20952)", () => {
installWebMonitorInboxUnitTestHooks();
type InboxOnMessage = NonNullable<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
async function tick() {
await new Promise((resolve) => setImmediate(resolve));
}
async function startInboxMonitor(onMessage: InboxOnMessage) {
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
return { listener, sock: getSock() };
}
it("processes recent append messages (within 60s of connect)", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Timestamp ~5 seconds ago — recent, should be processed.
const recentTs = Math.floor(Date.now() / 1000) - 5;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "recent-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "hello from group" },
messageTimestamp: recentTs,
pushName: "Tester",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("skips stale append messages (older than 60s before connect)", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Timestamp 5 minutes ago — stale history sync, should be skipped.
const staleTs = Math.floor(Date.now() / 1000) - 300;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "stale-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "old history sync" },
messageTimestamp: staleTs,
pushName: "OldTester",
},
],
});
await tick();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("skips append messages with NaN/non-finite timestamps", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// NaN timestamp should be treated as 0 (stale) and skipped.
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "nan-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "bad timestamp" },
messageTimestamp: NaN,
pushName: "BadTs",
},
],
});
await tick();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("handles Long-like protobuf timestamps correctly", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Baileys can deliver messageTimestamp as a Long object (from protobufjs).
// Number(longObj) calls valueOf() and returns the numeric value.
const recentTs = Math.floor(Date.now() / 1000) - 5;
const longLike = { low: recentTs, high: 0, unsigned: true, valueOf: () => recentTs };
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "long-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "long timestamp" },
messageTimestamp: longLike,
pushName: "LongTs",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("always processes notify messages regardless of timestamp", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Very old timestamp but type=notify — should always be processed.
const oldTs = Math.floor(Date.now() / 1000) - 86400;
sock.ev.emit("messages.upsert", {
type: "notify",
messages: [
{
key: { id: "notify-1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "normal message" },
messageTimestamp: oldTs,
pushName: "User",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
});

View File

@ -204,6 +204,62 @@ describe("web session", () => {
expect(inFlight).toBe(0);
});
it("lets different authDir queues flush independently", async () => {
let inFlightA = 0;
let inFlightB = 0;
let releaseA: (() => void) | null = null;
let releaseB: (() => void) | null = null;
const gateA = new Promise<void>((resolve) => {
releaseA = resolve;
});
const gateB = new Promise<void>((resolve) => {
releaseB = resolve;
});
const saveCredsA = vi.fn(async () => {
inFlightA += 1;
await gateA;
inFlightA -= 1;
});
const saveCredsB = vi.fn(async () => {
inFlightB += 1;
await gateB;
inFlightB -= 1;
});
useMultiFileAuthStateMock
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsA,
})
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsB,
});
await createWaSocket(false, false, { authDir: "/tmp/wa-a" });
const sockA = getLastSocket();
await createWaSocket(false, false, { authDir: "/tmp/wa-b" });
const sockB = getLastSocket();
sockA.ev.emit("creds.update", {});
sockB.ev.emit("creds.update", {});
await flushCredsUpdate();
expect(saveCredsA).toHaveBeenCalledTimes(1);
expect(saveCredsB).toHaveBeenCalledTimes(1);
expect(inFlightA).toBe(1);
expect(inFlightB).toBe(1);
(releaseA as (() => void) | null)?.();
(releaseB as (() => void) | null)?.();
await flushCredsUpdate();
await flushCredsUpdate();
expect(inFlightA).toBe(0);
expect(inFlightB).toBe(0);
});
it("rotates creds backup when creds.json is valid JSON", async () => {
const creds = mockCredsJsonSpies("{}");
const backupSuffix = path.join(

View File

@ -31,17 +31,24 @@ export {
webAuthExists,
} from "./auth-store.js";
let credsSaveQueue: Promise<void> = Promise.resolve();
// Per-authDir queues so multi-account creds saves don't block each other.
const credsSaveQueues = new Map<string, Promise<void>>();
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
function enqueueSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>,
): void {
credsSaveQueue = credsSaveQueue
const prev = credsSaveQueues.get(authDir) ?? Promise.resolve();
const next = prev
.then(() => safeSaveCreds(authDir, saveCreds, logger))
.catch((err) => {
logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
})
.finally(() => {
if (credsSaveQueues.get(authDir) === next) credsSaveQueues.delete(authDir);
});
credsSaveQueues.set(authDir, next);
}
async function safeSaveCreds(
@ -186,10 +193,37 @@ export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>)
export function getStatusCode(err: unknown) {
return (
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode
);
}
/** Await pending credential saves — scoped to one authDir, or all if omitted. */
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
if (authDir) {
return credsSaveQueues.get(authDir) ?? Promise.resolve();
}
return Promise.all(credsSaveQueues.values()).then(() => {});
}
/** Await pending credential saves, but don't hang forever on stalled I/O. */
export async function waitForCredsSaveQueueWithTimeout(
authDir: string,
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
): Promise<void> {
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
waitForCredsSaveQueue(authDir),
new Promise<void>((resolve) => {
flushTimeout = setTimeout(resolve, timeoutMs);
}),
]).finally(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
}
});
}
function safeStringify(value: unknown, limit = 800): string {
try {
const seen = new WeakSet();

View File

@ -28,6 +28,10 @@ function supportsUsageInStreaming(model: Model<Api>): boolean | undefined {
?.supportsUsageInStreaming;
}
function supportsStrictMode(model: Model<Api>): boolean | undefined {
return (model.compat as { supportsStrictMode?: boolean } | undefined)?.supportsStrictMode;
}
function createTemplateModel(provider: string, id: string): Model<Api> {
return {
id,
@ -94,6 +98,13 @@ function expectSupportsUsageInStreamingForcedOff(overrides?: Partial<Model<Api>>
expect(supportsUsageInStreaming(normalized)).toBe(false);
}
function expectSupportsStrictModeForcedOff(overrides?: Partial<Model<Api>>): void {
const model = { ...baseModel(), ...overrides };
delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model as Model<Api>);
expect(supportsStrictMode(normalized)).toBe(false);
}
function expectResolvedForwardCompat(
model: Model<Api> | undefined,
expected: { provider: string; id: string },
@ -226,6 +237,17 @@ describe("normalizeModelCompat", () => {
});
});
it("forces supportsStrictMode off for z.ai models", () => {
expectSupportsStrictModeForcedOff();
});
it("forces supportsStrictMode off for custom openai-completions provider", () => {
expectSupportsStrictModeForcedOff({
provider: "custom-cpa",
baseUrl: "https://cpa.example.com/v1",
});
});
it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => {
expectSupportsDeveloperRoleForcedOff({
provider: "qwen-proxy",
@ -283,6 +305,18 @@ describe("normalizeModelCompat", () => {
const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsStrictMode(normalized)).toBe(false);
});
it("respects explicit supportsStrictMode true on non-native endpoints", () => {
const model = {
...baseModel(),
provider: "custom-cpa",
baseUrl: "https://proxy.example.com/v1",
compat: { supportsStrictMode: true },
};
const normalized = normalizeModelCompat(model);
expect(supportsStrictMode(normalized)).toBe(true);
});
it("does not mutate caller model when forcing supportsDeveloperRole off", () => {
@ -296,16 +330,23 @@ describe("normalizeModelCompat", () => {
expect(normalized).not.toBe(model);
expect(supportsDeveloperRole(model)).toBeUndefined();
expect(supportsUsageInStreaming(model)).toBeUndefined();
expect(supportsStrictMode(model)).toBeUndefined();
expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsStrictMode(normalized)).toBe(false);
});
it("does not override explicit compat false", () => {
const model = baseModel();
model.compat = { supportsDeveloperRole: false, supportsUsageInStreaming: false };
model.compat = {
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
supportsStrictMode: false,
};
const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsStrictMode(normalized)).toBe(false);
});
});

View File

@ -54,9 +54,10 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
// The `developer` role and stream usage chunks are OpenAI-native behaviors.
// Many OpenAI-compatible backends reject `developer` and/or emit usage-only
// chunks that break strict parsers expecting choices[0]. For non-native
// openai-completions endpoints, force both compat flags off — unless the
// user has explicitly opted in via their model config.
// chunks that break strict parsers expecting choices[0]. Additionally, the
// `strict` boolean inside tools validation is rejected by several providers
// causing tool calls to be ignored. For non-native openai-completions endpoints,
// default these compat flags off unless explicitly opted in.
const compat = model.compat ?? undefined;
// When baseUrl is empty the pi-ai library defaults to api.openai.com, so
// leave compat unchanged and let default native behavior apply.
@ -64,13 +65,14 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
if (!needsForce) {
return model;
}
// Respect explicit user overrides: if the user has set a compat flag to
// true in their model definition, they know their endpoint supports it.
const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
if (forcedDeveloperRole && forcedUsageStreaming) {
const targetStrictMode = compat?.supportsStrictMode ?? false;
if (
compat?.supportsDeveloperRole !== undefined &&
compat?.supportsUsageInStreaming !== undefined &&
compat?.supportsStrictMode !== undefined
) {
return model;
}
@ -82,7 +84,12 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
...compat,
supportsDeveloperRole: forcedDeveloperRole || false,
supportsUsageInStreaming: forcedUsageStreaming || false,
supportsStrictMode: targetStrictMode,
}
: { supportsDeveloperRole: false, supportsUsageInStreaming: false },
: {
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
supportsStrictMode: false,
},
} as typeof model;
}

View File

@ -2,6 +2,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 {
expectOpenAIResponsesStrictSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager,
makeInMemorySessionManager,
@ -247,7 +248,24 @@ describe("sanitizeSessionHistory", () => {
expect(result).toEqual(mockMessages);
});
it("passes simple user-only history through for openai-completions", async () => {
it("sanitizes tool call ids for OpenAI-compatible responses providers", async () => {
setNonGoogleModelApi();
await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "openai-responses",
provider: "custom",
sessionManager: mockSessionManager,
sessionId: TEST_SESSION_ID,
});
expectOpenAIResponsesStrictSanitizeCall(
mockedHelpers.sanitizeSessionMessagesImages,
mockMessages,
);
});
it("sanitizes tool call ids for openai-completions", async () => {
setNonGoogleModelApi();
const result = await sanitizeSessionHistory({

View File

@ -702,6 +702,26 @@ describe("wrapStreamFnTrimToolCallNames", () => {
expect(finalToolCall.name).toBe("read");
expect(finalToolCall.id).toBe("call_42");
});
it("reassigns duplicate tool call ids within a message to unique fallbacks", async () => {
const finalToolCallA = { type: "toolCall", name: " read ", id: " edit:22 " };
const finalToolCallB = { type: "toolCall", name: " write ", id: "edit:22" };
const finalMessage = { role: "assistant", content: [finalToolCallA, finalToolCallB] };
const baseFn = vi.fn(() =>
createFakeStream({
events: [],
resultMessage: finalMessage,
}),
);
const stream = await invokeWrappedStream(baseFn);
await stream.result();
expect(finalToolCallA.name).toBe("read");
expect(finalToolCallB.name).toBe("write");
expect(finalToolCallA.id).toBe("edit:22");
expect(finalToolCallB.id).toBe("call_auto_1");
});
});
describe("wrapStreamFnRepairMalformedToolCallArguments", () => {

View File

@ -667,6 +667,7 @@ function normalizeToolCallIdsInMessage(message: unknown): void {
}
let fallbackIndex = 1;
const assignedIds = new Set<string>();
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
@ -678,20 +679,23 @@ function normalizeToolCallIdsInMessage(message: unknown): void {
if (typeof typedBlock.id === "string") {
const trimmedId = typedBlock.id.trim();
if (trimmedId) {
if (!assignedIds.has(trimmedId)) {
if (typedBlock.id !== trimmedId) {
typedBlock.id = trimmedId;
}
usedIds.add(trimmedId);
assignedIds.add(trimmedId);
continue;
}
}
}
let fallbackId = "";
while (!fallbackId || usedIds.has(fallbackId)) {
while (!fallbackId || usedIds.has(fallbackId) || assignedIds.has(fallbackId)) {
fallbackId = `call_auto_${fallbackIndex++}`;
}
typedBlock.id = fallbackId;
usedIds.add(fallbackId);
assignedIds.add(fallbackId);
}
}

View File

@ -40,6 +40,9 @@ const {
computeAdaptiveChunkRatio,
isOversizedForSummary,
readWorkspaceContextForSummary,
isRealConversationMessage,
hasMeaningfulText,
hasNearbyMeaningfulUserMessage,
BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO,
SAFETY_MARGIN,
@ -1583,3 +1586,231 @@ describe("readWorkspaceContextForSummary", () => {
},
);
});
// ---------------------------------------------------------------------------
// isRealConversationMessage — content-aware checks (issue #40727)
// ---------------------------------------------------------------------------
describe("isRealConversationMessage", () => {
it("accepts a user message with meaningful text", () => {
const msg = castAgentMessage({ role: "user", content: "Fix the bug", timestamp: 0 });
expect(isRealConversationMessage(msg)).toBe(true);
});
it("accepts an assistant message with meaningful text", () => {
const msg = castAgentMessage({
role: "assistant",
content: [{ type: "text", text: "Here is the fix" }],
timestamp: 0,
});
expect(isRealConversationMessage(msg)).toBe(true);
});
it("rejects a user message with empty content", () => {
const msg = castAgentMessage({ role: "user", content: "", timestamp: 0 });
expect(isRealConversationMessage(msg)).toBe(false);
});
it("rejects an assistant message that is only HEARTBEAT_OK", () => {
const msg = castAgentMessage({
role: "assistant",
content: "HEARTBEAT_OK",
timestamp: 0,
});
expect(isRealConversationMessage(msg)).toBe(false);
});
it("rejects an assistant message that is only NO_REPLY", () => {
const msg = castAgentMessage({
role: "assistant",
content: "NO_REPLY",
timestamp: 0,
});
expect(isRealConversationMessage(msg)).toBe(false);
});
it("rejects a user message with only whitespace", () => {
const msg = castAgentMessage({ role: "user", content: " \n ", timestamp: 0 });
expect(isRealConversationMessage(msg)).toBe(false);
});
it("accepts an assistant message with HEARTBEAT_OK embedded in real text", () => {
const msg = castAgentMessage({
role: "assistant",
content: "Status: HEARTBEAT_OK — also deployed v2",
timestamp: 0,
});
expect(isRealConversationMessage(msg)).toBe(true);
});
it("rejects a system message", () => {
const msg = castAgentMessage({ role: "system", content: "You are helpful", timestamp: 0 });
expect(isRealConversationMessage(msg)).toBe(false);
});
it("accepts a toolResult when no window is provided (conservative)", () => {
const msg = castAgentMessage({
role: "toolResult",
toolCallId: "tc1",
toolName: "read_file",
content: "file contents",
isError: false,
timestamp: 0,
});
expect(isRealConversationMessage(msg)).toBe(true);
});
it("accepts a toolResult with a nearby meaningful user message", () => {
const window: AgentMessage[] = [
castAgentMessage({ role: "user", content: "Please read main.ts", timestamp: 0 }),
castAgentMessage({
role: "assistant",
content: [{ type: "text", text: "Reading..." }],
timestamp: 0,
}),
castAgentMessage({
role: "toolResult",
toolCallId: "tc1",
toolName: "read_file",
content: "file data",
isError: false,
timestamp: 0,
}),
];
expect(isRealConversationMessage(window[2], window, 2)).toBe(true);
});
it("rejects a toolResult when nearby user messages are heartbeat-only", () => {
const window: AgentMessage[] = [
castAgentMessage({ role: "user", content: "", timestamp: 0 }),
castAgentMessage({
role: "assistant",
content: "HEARTBEAT_OK",
timestamp: 0,
}),
castAgentMessage({
role: "toolResult",
toolCallId: "tc1",
toolName: "heartbeat_check",
content: "ok",
isError: false,
timestamp: 0,
}),
];
expect(isRealConversationMessage(window[2], window, 2)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// hasMeaningfulText
// ---------------------------------------------------------------------------
describe("hasMeaningfulText", () => {
it("returns true for normal text", () => {
const msg = castAgentMessage({ role: "user", content: "Hello world", timestamp: 0 });
expect(hasMeaningfulText(msg)).toBe(true);
});
it("returns false for empty string", () => {
const msg = castAgentMessage({ role: "user", content: "", timestamp: 0 });
expect(hasMeaningfulText(msg)).toBe(false);
});
it("returns false for HEARTBEAT_OK only", () => {
const msg = castAgentMessage({ role: "assistant", content: "HEARTBEAT_OK", timestamp: 0 });
expect(hasMeaningfulText(msg)).toBe(false);
});
it("returns false for NO_REPLY only", () => {
const msg = castAgentMessage({ role: "assistant", content: "NO_REPLY", timestamp: 0 });
expect(hasMeaningfulText(msg)).toBe(false);
});
it("returns true when boilerplate is mixed with real text", () => {
const msg = castAgentMessage({
role: "assistant",
content: "Done. NO_REPLY",
timestamp: 0,
});
expect(hasMeaningfulText(msg)).toBe(true);
});
it("returns false for array content with no text blocks", () => {
const msg = castAgentMessage({
role: "assistant",
content: [{ type: "image", source: "data" }],
timestamp: 0,
});
expect(hasMeaningfulText(msg)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// hasNearbyMeaningfulUserMessage
// ---------------------------------------------------------------------------
describe("hasNearbyMeaningfulUserMessage", () => {
it("finds a meaningful user message within lookback window", () => {
const window: AgentMessage[] = [
castAgentMessage({ role: "user", content: "Do something", timestamp: 0 }),
castAgentMessage({ role: "assistant", content: "ok", timestamp: 0 }),
castAgentMessage({
role: "toolResult",
toolCallId: "tc1",
toolName: "x",
content: "y",
isError: false,
timestamp: 0,
}),
];
expect(hasNearbyMeaningfulUserMessage(window, 2)).toBe(true);
});
it("returns false when user messages are empty", () => {
const window: AgentMessage[] = [
castAgentMessage({ role: "user", content: "", timestamp: 0 }),
castAgentMessage({
role: "toolResult",
toolCallId: "tc1",
toolName: "x",
content: "y",
isError: false,
timestamp: 0,
}),
];
expect(hasNearbyMeaningfulUserMessage(window, 1)).toBe(false);
});
it("returns false when meaningful user message is beyond lookback", () => {
const window: AgentMessage[] = [
castAgentMessage({ role: "user", content: "Real message", timestamp: 0 }),
// 6 filler messages (beyond lookback of 5)
...Array.from({ length: 6 }, () =>
castAgentMessage({ role: "assistant", content: "HEARTBEAT_OK", timestamp: 0 }),
),
castAgentMessage({
role: "toolResult",
toolCallId: "tc1",
toolName: "x",
content: "y",
isError: false,
timestamp: 0,
}),
];
expect(hasNearbyMeaningfulUserMessage(window, 7)).toBe(false);
});
it("returns false when index is 0 (no preceding messages)", () => {
const window: AgentMessage[] = [
castAgentMessage({
role: "toolResult",
toolCallId: "tc1",
toolName: "x",
content: "y",
isError: false,
timestamp: 0,
}),
];
expect(hasNearbyMeaningfulUserMessage(window, 0)).toBe(false);
});
});

View File

@ -179,8 +179,72 @@ function formatToolFailuresSection(failures: ToolFailure[]): string {
return `\n\n## Tool Failures\n${lines.join("\n")}`;
}
function isRealConversationMessage(message: AgentMessage): boolean {
return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
/** Boilerplate tokens that do not represent real user/assistant conversation. */
const BOILERPLATE_TOKENS = new Set(["HEARTBEAT_OK", "NO_REPLY"]);
/**
* Returns `true` when `message` represents genuine conversation content that
* justifies keeping a compaction window.
*
* The old implementation only checked `role`, which let heartbeat polls
* (empty user content) and HEARTBEAT_OK / NO_REPLY assistant replies slip
* through see issue #40727.
*
* Rules:
* user / assistant must contain meaningful text (non-empty after trimming
* boilerplate tokens).
* toolResult only counts when a nearby user message in the same window
* carries meaningful text (prevents heartbeat-only tool-use chains from
* being treated as real conversation).
*/
function isRealConversationMessage(
message: AgentMessage,
window?: AgentMessage[],
index?: number,
): boolean {
if (message.role === "user" || message.role === "assistant") {
return hasMeaningfulText(message);
}
if (message.role === "toolResult") {
// Without a surrounding window we cannot verify context — be conservative
// and accept the message.
if (!window || index === undefined) {
return true;
}
return hasNearbyMeaningfulUserMessage(window, index);
}
return false;
}
/** Check whether a message carries non-boilerplate text content. */
function hasMeaningfulText(message: AgentMessage): boolean {
const text = extractMessageText(message);
if (text.length === 0) {
return false;
}
// Strip known boilerplate tokens and see if anything remains.
let stripped = text;
for (const token of BOILERPLATE_TOKENS) {
stripped = stripped.replaceAll(token, "");
}
return stripped.trim().length > 0;
}
/**
* Scan backwards (up to 5 messages) from `index` looking for a user message
* that contains meaningful text. This lets tool-result messages inherit
* "realness" from the user turn that triggered them.
*/
function hasNearbyMeaningfulUserMessage(window: AgentMessage[], index: number): boolean {
const lookback = 5;
const start = Math.max(0, index - lookback);
for (let i = index - 1; i >= start; i--) {
const msg = window[i];
if (msg.role === "user" && hasMeaningfulText(msg)) {
return true;
}
}
return false;
}
function computeFileLists(fileOps: FileOperations): {
@ -702,7 +766,8 @@ async function readWorkspaceContextForSummary(): Promise<string> {
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions: eventInstructions, signal } = event;
if (!preparation.messagesToSummarize.some(isRealConversationMessage)) {
const allMessages = [...preparation.turnPrefixMessages, ...preparation.messagesToSummarize];
if (!allMessages.some((msg, idx, arr) => isRealConversationMessage(msg, arr, idx))) {
log.warn(
"Compaction safeguard: cancelling compaction with no real conversation messages to summarize.",
);
@ -1005,6 +1070,9 @@ export const __testing = {
computeAdaptiveChunkRatio,
isOversizedForSummary,
readWorkspaceContextForSummary,
isRealConversationMessage,
hasMeaningfulText,
hasNearbyMeaningfulUserMessage,
BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO,
SAFETY_MARGIN,

View File

@ -29,6 +29,54 @@ const buildDuplicateIdCollisionInput = () =>
},
]);
const buildRepeatedRawIdInput = () =>
castAgentMessages([
{
role: "assistant",
content: [
{ type: "toolCall", id: "edit:22", name: "edit", arguments: {} },
{ type: "toolCall", id: "edit:22", name: "edit", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "edit:22",
toolName: "edit",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "edit:22",
toolName: "edit",
content: [{ type: "text", text: "two" }],
},
]);
const buildRepeatedSharedToolResultIdInput = () =>
castAgentMessages([
{
role: "assistant",
content: [
{ type: "toolCall", id: "edit:22", name: "edit", arguments: {} },
{ type: "toolCall", id: "edit:22", name: "edit", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "edit:22",
toolUseId: "edit:22",
toolName: "edit",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "edit:22",
toolUseId: "edit:22",
toolName: "edit",
content: [{ type: "text", text: "two" }],
},
]);
function expectCollisionIdsRemainDistinct(
out: AgentMessage[],
mode: "strict" | "strict9",
@ -111,6 +159,26 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expectCollisionIdsRemainDistinct(out, "strict");
});
it("reuses one rewritten id when a tool result carries matching toolCallId and toolUseId", () => {
const input = buildRepeatedSharedToolResultIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).not.toBe(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict");
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string };
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string };
expect(r1.toolUseId).toBe(aId);
expect(r2.toolUseId).toBe(bId);
});
it("assigns distinct IDs when identical raw tool call ids repeat", () => {
const input = buildRepeatedRawIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).not.toBe(input);
expectCollisionIdsRemainDistinct(out, "strict");
});
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
const longA = `call_${"a".repeat(60)}`;
const longB = `call_${"a".repeat(59)}b`;
@ -181,6 +249,16 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(aId).not.toMatch(/[_-]/);
expect(bId).not.toMatch(/[_-]/);
});
it("assigns distinct strict IDs when identical raw tool call ids repeat", () => {
const input = buildRepeatedRawIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict");
expect(aId).not.toMatch(/[_-]/);
expect(bId).not.toMatch(/[_-]/);
});
});
describe("strict9 mode (Mistral tool call IDs)", () => {
@ -231,5 +309,27 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(aId.length).toBe(9);
expect(bId.length).toBe(9);
});
it("assigns distinct strict9 IDs when identical raw tool call ids repeat", () => {
const input = buildRepeatedRawIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).not.toBe(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict9");
expect(aId.length).toBe(9);
expect(bId.length).toBe(9);
});
it("reuses one rewritten strict9 id when a tool result carries matching toolCallId and toolUseId", () => {
const input = buildRepeatedSharedToolResultIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).not.toBe(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict9");
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string };
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string };
expect(r1.toolUseId).toBe(aId);
expect(r2.toolUseId).toBe(bId);
});
});
});

View File

@ -144,9 +144,55 @@ function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCal
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
}
function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
resolveAssistantId: (id: string) => string;
resolveToolResultId: (id: string) => string;
} {
const used = new Set<string>();
const assistantOccurrences = new Map<string, number>();
const orphanToolResultOccurrences = new Map<string, number>();
const pendingByRawId = new Map<string, string[]>();
const allocate = (seed: string): string => {
const next = makeUniqueToolId({ id: seed, used, mode });
used.add(next);
return next;
};
const resolveAssistantId = (id: string): string => {
const occurrence = (assistantOccurrences.get(id) ?? 0) + 1;
assistantOccurrences.set(id, occurrence);
const next = allocate(occurrence === 1 ? id : `${id}:${occurrence}`);
const pending = pendingByRawId.get(id);
if (pending) {
pending.push(next);
} else {
pendingByRawId.set(id, [next]);
}
return next;
};
const resolveToolResultId = (id: string): string => {
const pending = pendingByRawId.get(id);
if (pending && pending.length > 0) {
const next = pending.shift()!;
if (pending.length === 0) {
pendingByRawId.delete(id);
}
return next;
}
const occurrence = (orphanToolResultOccurrences.get(id) ?? 0) + 1;
orphanToolResultOccurrences.set(id, occurrence);
return allocate(`${id}:tool_result:${occurrence}`);
};
return { resolveAssistantId, resolveToolResultId };
}
function rewriteAssistantToolCallIds(params: {
message: Extract<AgentMessage, { role: "assistant" }>;
resolve: (id: string) => string;
resolveId: (id: string) => string;
}): Extract<AgentMessage, { role: "assistant" }> {
const content = params.message.content;
if (!Array.isArray(content)) {
@ -168,7 +214,7 @@ function rewriteAssistantToolCallIds(params: {
) {
return block;
}
const nextId = params.resolve(id);
const nextId = params.resolveId(id);
if (nextId === id) {
return block;
}
@ -184,7 +230,7 @@ function rewriteAssistantToolCallIds(params: {
function rewriteToolResultIds(params: {
message: Extract<AgentMessage, { role: "toolResult" }>;
resolve: (id: string) => string;
resolveId: (id: string) => string;
}): Extract<AgentMessage, { role: "toolResult" }> {
const toolCallId =
typeof params.message.toolCallId === "string" && params.message.toolCallId
@ -192,9 +238,14 @@ function rewriteToolResultIds(params: {
: undefined;
const toolUseId = (params.message as { toolUseId?: unknown }).toolUseId;
const toolUseIdStr = typeof toolUseId === "string" && toolUseId ? toolUseId : undefined;
const sharedRawId =
toolCallId && toolUseIdStr && toolCallId === toolUseIdStr ? toolCallId : undefined;
const nextToolCallId = toolCallId ? params.resolve(toolCallId) : undefined;
const nextToolUseId = toolUseIdStr ? params.resolve(toolUseIdStr) : undefined;
const sharedResolvedId = sharedRawId ? params.resolveId(sharedRawId) : undefined;
const nextToolCallId =
sharedResolvedId ?? (toolCallId ? params.resolveId(toolCallId) : undefined);
const nextToolUseId =
sharedResolvedId ?? (toolUseIdStr ? params.resolveId(toolUseIdStr) : undefined);
if (nextToolCallId === toolCallId && nextToolUseId === toolUseIdStr) {
return params.message;
@ -219,21 +270,11 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
): AgentMessage[] {
// Strict mode: only [a-zA-Z0-9]
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `ab`).
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix.
const map = new Map<string, string>();
const used = new Set<string>();
const resolve = (id: string) => {
const existing = map.get(id);
if (existing) {
return existing;
}
const next = makeUniqueToolId({ id, used, mode });
map.set(id, next);
used.add(next);
return next;
};
// Sanitization can introduce collisions, and some providers also reject raw
// duplicate tool-call IDs. Track assistant occurrences in-order so repeated
// raw IDs receive distinct rewritten IDs, while matching tool results consume
// the same rewritten IDs in encounter order.
const { resolveAssistantId, resolveToolResultId } = createOccurrenceAwareResolver(mode);
let changed = false;
const out = messages.map((msg) => {
@ -244,7 +285,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
if (role === "assistant") {
const next = rewriteAssistantToolCallIds({
message: msg as Extract<AgentMessage, { role: "assistant" }>,
resolve,
resolveId: resolveAssistantId,
});
if (next !== msg) {
changed = true;
@ -254,7 +295,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
if (role === "toolResult") {
const next = rewriteToolResultIds({
message: msg as Extract<AgentMessage, { role: "toolResult" }>,
resolve,
resolveId: resolveToolResultId,
});
if (next !== msg) {
changed = true;

View File

@ -78,7 +78,10 @@ export function resolveTranscriptPolicy(params: {
provider,
modelId,
});
const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions";
const requiresOpenAiCompatibleToolIdSanitization =
params.modelApi === "openai-completions" ||
(!isOpenAi &&
(params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses"));
// Anthropic Claude endpoints can reject replayed `thinking` blocks unless the
// original signatures are preserved byte-for-byte. Drop them at send-time to

View File

@ -347,6 +347,7 @@ describe("resolveNodeCommandAllowlist", () => {
expect(allow.has("notifications.actions")).toBe(true);
expect(allow.has("device.permissions")).toBe(true);
expect(allow.has("device.health")).toBe(true);
expect(allow.has("callLog.search")).toBe(true);
expect(allow.has("system.notify")).toBe(true);
});

View File

@ -36,6 +36,8 @@ const CONTACTS_DANGEROUS_COMMANDS = ["contacts.add"];
const CALENDAR_COMMANDS = ["calendar.events"];
const CALENDAR_DANGEROUS_COMMANDS = ["calendar.add"];
const CALL_LOG_COMMANDS = ["callLog.search"];
const REMINDERS_COMMANDS = ["reminders.list"];
const REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"];
@ -93,6 +95,7 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
...ANDROID_DEVICE_COMMANDS,
...CONTACTS_COMMANDS,
...CALENDAR_COMMANDS,
...CALL_LOG_COMMANDS,
...REMINDERS_COMMANDS,
...PHOTOS_COMMANDS,
...MOTION_COMMANDS,

View File

@ -226,6 +226,30 @@ describe("ws connect policy", () => {
expect(shouldSkipControlUiPairing(strict, "operator", true)).toBe(true);
});
test("auth.mode=none skips pairing for operator control-ui only", () => {
const controlUi = resolveControlUiAuthPolicy({
isControlUi: true,
controlUiConfig: undefined,
deviceRaw: null,
});
const nonControlUi = resolveControlUiAuthPolicy({
isControlUi: false,
controlUiConfig: undefined,
deviceRaw: null,
});
// Control UI + operator + auth.mode=none: skip pairing (the fix for #42931)
expect(shouldSkipControlUiPairing(controlUi, "operator", false, "none")).toBe(true);
// Control UI + node role + auth.mode=none: still require pairing
expect(shouldSkipControlUiPairing(controlUi, "node", false, "none")).toBe(false);
// Non-Control-UI + operator + auth.mode=none: still require pairing
// (prevents #43478 regression where ALL clients bypassed pairing)
expect(shouldSkipControlUiPairing(nonControlUi, "operator", false, "none")).toBe(false);
// Control UI + operator + auth.mode=shared-key: no change
expect(shouldSkipControlUiPairing(controlUi, "operator", false, "shared-key")).toBe(false);
// Control UI + operator + no authMode: no change
expect(shouldSkipControlUiPairing(controlUi, "operator", false)).toBe(false);
});
test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
const cases: Array<{
role: "operator" | "node";

View File

@ -3,6 +3,7 @@ import type { GatewayRole } from "../../role-policy.js";
import { roleCanSkipDeviceIdentity } from "../../role-policy.js";
export type ControlUiAuthPolicy = {
isControlUi: boolean;
allowInsecureAuthConfigured: boolean;
dangerouslyDisableDeviceAuth: boolean;
allowBypass: boolean;
@ -24,6 +25,7 @@ export function resolveControlUiAuthPolicy(params: {
const dangerouslyDisableDeviceAuth =
params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true;
return {
isControlUi: params.isControlUi,
allowInsecureAuthConfigured,
dangerouslyDisableDeviceAuth,
// `allowInsecureAuth` must not bypass secure-context/device-auth requirements.
@ -36,10 +38,21 @@ export function shouldSkipControlUiPairing(
policy: ControlUiAuthPolicy,
role: GatewayRole,
trustedProxyAuthOk = false,
authMode?: string,
): boolean {
if (trustedProxyAuthOk) {
return true;
}
// When auth is completely disabled (mode=none), there is no shared secret
// or token to gate pairing. Requiring pairing in this configuration adds
// friction without security value since any client can already connect
// without credentials. Guard with policy.isControlUi because this function
// is called for ALL clients (not just Control UI) at the call site.
// Scope to operator role so node-role sessions still need device identity
// (#43478 was reverted for skipping ALL clients).
if (policy.isControlUi && role === "operator" && authMode === "none") {
return true;
}
// dangerouslyDisableDeviceAuth is the break-glass path for Control UI
// operators. Keep pairing aligned with the missing-device bypass, including
// open-auth deployments where there is no shared token/password to prove.

View File

@ -681,7 +681,13 @@ export function attachGatewayWsMessageHandler(params: {
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
}) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk);
}) ||
shouldSkipControlUiPairing(
controlUiAuthPolicy,
role,
trustedProxyAuthOk,
resolvedAuth.mode,
);
if (device && devicePublicKey && !skipPairing) {
const formatAuditList = (items: string[] | undefined): string => {
if (!items || items.length === 0) {

View File

@ -695,6 +695,7 @@ async function deliverOutboundPayloadsCore(
const sendOverrides = {
replyToId: effectivePayload.replyToId ?? params.replyToId ?? undefined,
threadId: params.threadId ?? undefined,
forceDocument: params.forceDocument,
};
if (handler.sendPayload && effectivePayload.channelData) {
const delivery = await handler.sendPayload(effectivePayload, sendOverrides);

View File

@ -2,7 +2,7 @@ import { completeSimple, type AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { ensureCustomApiRegistered } from "../agents/custom-api-registry.js";
import { getApiKeyForModel } from "../agents/model-auth.js";
import { resolveModel } from "../agents/pi-embedded-runner/model.js";
import { resolveModelAsync } from "../agents/pi-embedded-runner/model.js";
import type { OpenClawConfig } from "../config/config.js";
import { withEnv } from "../test-utils/env.js";
import * as tts from "./tts.js";
@ -20,13 +20,13 @@ vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: vi.fn(async () => null),
}));
vi.mock("../agents/pi-embedded-runner/model.js", () => ({
resolveModel: vi.fn((provider: string, modelId: string) => ({
function createResolvedModel(provider: string, modelId: string, api = "openai-completions") {
return {
model: {
provider,
id: modelId,
name: modelId,
api: "openai-completions",
api,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@ -35,7 +35,16 @@ vi.mock("../agents/pi-embedded-runner/model.js", () => ({
},
authStorage: { profiles: {} },
modelRegistry: { find: vi.fn() },
})),
};
}
vi.mock("../agents/pi-embedded-runner/model.js", () => ({
resolveModel: vi.fn((provider: string, modelId: string) =>
createResolvedModel(provider, modelId),
),
resolveModelAsync: vi.fn(async (provider: string, modelId: string) =>
createResolvedModel(provider, modelId),
),
}));
vi.mock("../agents/model-auth.js", () => ({
@ -411,25 +420,16 @@ describe("tts", () => {
timeoutMs: 30_000,
});
expect(resolveModel).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg);
expect(resolveModelAsync).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg);
});
it("registers the Ollama api before direct summarization", async () => {
vi.mocked(resolveModel).mockReturnValue({
vi.mocked(resolveModelAsync).mockResolvedValue({
...createResolvedModel("ollama", "qwen3:8b", "ollama"),
model: {
provider: "ollama",
id: "qwen3:8b",
name: "qwen3:8b",
api: "ollama",
...createResolvedModel("ollama", "qwen3:8b", "ollama").model,
baseUrl: "http://127.0.0.1:11434",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
},
authStorage: { profiles: {} } as never,
modelRegistry: { find: vi.fn() } as never,
} as never);
await summarizeText({