diff --git a/AGENTS.md b/AGENTS.md index b381ceb2f..0ef57992e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - Tests: colocated `*.test.ts`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. +- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias). - Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). - Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs). - Core channel docs: `docs/channels/` @@ -23,13 +24,14 @@ - Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”. ## exe.dev VM ops (general) -- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal). -- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`). -- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset. -- Restart: exe.dev often lacks systemd user bus; stop old gateway and run: +- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set). +- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops. +- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`). +- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set. +- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix). +- Restart: stop old gateway and run: `pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &` -- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`. -- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH. +- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`. ## Build, Test, and Development Commands - Runtime baseline: Node **22+** (keep Node + Bun paths working). @@ -128,6 +130,10 @@ - **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. +- Lint/format churn: + - If staged+unstaged diffs are formatting-only, auto-resolve without asking. + - If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation. + - Only ask when changes are semantic (logic/data/behavior). - Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fe6e5a8..714a3eb54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,27 @@ Docs: https://docs.clawd.bot ### Changes - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. +- CLI: add live auth probes to `clawdbot models status` for per-profile verification. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. - Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. ### Fixes +- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. +- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies. +- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. +- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. +- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. +- CLI: suppress diagnostic session/run noise during auth probes. +- CLI: hide auth probe timeout warnings from embedded runs. +- CLI: render auth probe results as a table in `clawdbot models status`. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. +- TUI: render Gateway slash-command replies as system output (for example, `/context`). - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. +- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero. ## 2026.1.22 diff --git a/README.md b/README.md index a08760ae6..eb34f3e41 100644 --- a/README.md +++ b/README.md @@ -478,28 +478,29 @@ Thanks to all clawtributors:

steipete bohdanpodvirnyi joaohlisboa mneves75 MatthieuBizien MaudeBot rahthakor vrknetha radek-paclt Tobias Bischoff - joshp123 mukhtharcm maxsumrall xadenryan juanpablodlc hsrvc magimetal meaningfool NicholasSpisak sebslight - abhisekbasu1 jamesgroat zerone0x claude SocialNerd42069 Hyaxia dantelex daveonkels mteam88 Eng. Juan Combetto - Mariano Belinky dbhurley TSavo julianengel benithors bradleypriest timolins nachx639 sreekaransrinath gupsammy - cristip73 nachoiacovino Vasanth Rao Naik Sabavat cpojer lc0rp scald gumadeiras andranik-sahakyan davidguttman sleontenko - sircrumpet peschee rafaelreis-r thewilloftheshadow ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski CashWilliams - rdev osolmaz joshrad-dev kiranjd adityashaw2 sheeek artuskg onutc tyler6204 manuelhettich - minghinmatthewlam myfunc vignesh07 buddyh connorshea mcinteerj dependabot[bot] John-Rood timkrase gerardward2007 - obviyus tosh-hamburg azade-c roshanasingh4 bjesuiter cheeeee Josh Phillips Whoaa512 YuriNachos chriseidhof - ysqander dlauer superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr - HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 ngutman petter-b pkrmf RandyVentures - robbyczgw-cla Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot - mkbehr neist chrisrodz czekaj Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 - manmal ogulcancelik pasogott petradonka rubyrunsstuff sibbl siddhantjain suminhthanh VACInc wes-davis - zats 24601 ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa oswalpalash + joshp123 mukhtharcm maxsumrall xadenryan juanpablodlc hsrvc magimetal meaningfool patelhiren NicholasSpisak + sebslight abhisekbasu1 zerone0x jamesgroat claude SocialNerd42069 Hyaxia dantelex daveonkels mteam88 + Eng. Juan Combetto Mariano Belinky dbhurley TSavo julianengel benithors bradleypriest timolins nachx639 pvoo + sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat iHildy cpojer lc0rp scald + gumadeiras andranik-sahakyan davidguttman sleontenko rodrigouroz sircrumpet peschee rafaelreis-r thewilloftheshadow ratulsarna + lutr0 danielz1z emanuelst KristijanJovanovski CashWilliams rdev osolmaz joshrad-dev kiranjd adityashaw2 + sheeek artuskg onutc pauloportella tyler6204 neooriginal manuelhettich minghinmatthewlam myfunc travisirby + vignesh07 buddyh connorshea mcinteerj dependabot[bot] John-Rood timkrase gerardward2007 obviyus tosh-hamburg + azade-c roshanasingh4 bjesuiter cheeeee Josh Phillips Whoaa512 YuriNachos chriseidhof dlauer ysqander + robbyczgw-cla superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy + imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 ngutman petter-b pkrmf RandyVentures Ryan Lisse + dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist + sibbl chrisrodz czekaj Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal + ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats + 24601 ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account Syhids Aaron Konyer aaronveklabs adam91holt cash-echo-bot Clawd ClawdFx erik-agens fcatuhe ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML VAC zknicker aj47 alejandro maza andrewting19 Andrii anpoirier - Asleep123 bolismauro conhecendoia Dimitrios Ploutarchos Drake Thomsen Felix Krause gtsifrikas HazAT hrdwdmrbl hugobarauna - Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal martinpucik Matt mini - Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 odysseus0 prathamdby ptn1411 reeltimeapps - RLTCmpe rodrigouroz Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke testingabc321 The Admiral thesash - Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai Zach Knickerbocker Alphonse-arianee Azade carlulsoe - ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pauloportella pcty-nextgen-ios-builder Quentin Randy Torres + Asleep123 bolismauro conhecendoia Dimitrios Ploutarchos Drake Thomsen Felix Krause ganghyun kim gtsifrikas HazAT hrdwdmrbl + hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal martinpucik + Matt mini Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 odnxe prathamdby ptn1411 + reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha shiv19 siraht snopoke testingabc321 The Admiral + thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai Zach Knickerbocker Alphonse-arianee Azade + carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 7a99b672a..a98a29aa0 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.clawdbot.android" minSdk = 31 targetSdk = 36 - versionCode = 202601210 - versionName = "2026.1.21" + versionCode = 202601230 + versionName = "2026.1.23" } buildTypes { diff --git a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt index 855a0de7c..d54ed1e08 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt @@ -8,10 +8,14 @@ object WakeWords { return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } } + fun parseIfChanged(input: String, current: List): List? { + val parsed = parseCommaSeparated(input) + return if (parsed == current) null else parsed + } + fun sanitize(words: List, defaults: List): List { val cleaned = words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } return cleaned.ifEmpty { defaults } } } - diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt index aee1059bd..e3a9b3ecb 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -49,7 +51,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode import com.clawdbot.android.MainViewModel import com.clawdbot.android.NodeForegroundService import com.clawdbot.android.VoiceWakeMode +import com.clawdbot.android.WakeWords @Composable fun SettingsSheet(viewModel: MainViewModel) { @@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) { val listState = rememberLazyListState() val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + var wakeWordsHadFocus by remember { mutableStateOf(false) } val deviceModel = remember { listOfNotNull(Build.MANUFACTURER, Build.MODEL) @@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) { } LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } + val commitWakeWords = { + val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) + if (parsed != null) { + viewModel.setWakeWords(parsed) + } + } val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> @@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) { value = wakeWordsText, onValueChange = setWakeWordsText, label = { Text("Wake Words (comma-separated)") }, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier.fillMaxWidth().onFocusChanged { focusState -> + if (focusState.isFocused) { + wakeWordsHadFocus = true + } else if (wakeWordsHadFocus) { + wakeWordsHadFocus = false + commitWakeWords() + } + }, singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + commitWakeWords() + focusManager.clearFocus() + }, + ), ) } - item { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = { - val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText) - viewModel.setWakeWords(parsed) - }, - enabled = isConnected, - ) { - Text("Save + Sync") - } - - Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } - } - } + item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } item { Text( if (isConnected) { diff --git a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt index 1d61383e8..9363e810c 100644 --- a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt +++ b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt @@ -1,6 +1,7 @@ package com.clawdbot.android import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class WakeWordsTest { @@ -32,5 +33,18 @@ class WakeWordsTest { assertEquals("w1", sanitized.first()) assertEquals("w${WakeWords.maxWords}", sanitized.last()) } -} + @Test + fun parseIfChangedSkipsWhenUnchanged() { + val current = listOf("clawd", "claude") + val parsed = WakeWords.parseIfChanged(" clawd , claude ", current) + assertNull(parsed) + } + + @Test + fun parseIfChangedReturnsUpdatedList() { + val current = listOf("clawd") + val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current) + assertEquals(listOf("clawd", "jarvis"), parsed) + } +} diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 1b7b5b3d5..02785e4f0 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.21 + 2026.1.23 CFBundleVersion - 20260121 + 20260123 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift index d13edafe2..5aef87b0c 100644 --- a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift +++ b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift @@ -1,8 +1,10 @@ import SwiftUI +import Combine struct VoiceWakeWordsSettingsView: View { @Environment(NodeAppModel.self) private var appModel @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() + @FocusState private var focusedTriggerIndex: Int? @State private var syncTask: Task? var body: some View { @@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View { TextField("Wake word", text: self.binding(for: index)) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .focused(self.$focusedTriggerIndex, equals: index) + .onSubmit { + self.commitTriggerWords() + } } .onDelete(perform: self.removeWords) @@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View { .onAppear { if self.triggerWords.isEmpty { self.triggerWords = VoiceWakePreferences.defaultTriggerWords + self.commitTriggerWords() } } - .onChange(of: self.triggerWords) { _, newValue in - // Keep local voice wake responsive even if the gateway isn't connected yet. - VoiceWakePreferences.saveTriggerWords(newValue) - - let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) - self.syncTask?.cancel() - self.syncTask = Task { [snapshot, weak appModel = self.appModel] in - try? await Task.sleep(nanoseconds: 650_000_000) - await appModel?.setGlobalWakeWords(snapshot) + .onChange(of: self.focusedTriggerIndex) { oldValue, newValue in + guard oldValue != nil, oldValue != newValue else { return } + self.commitTriggerWords() + } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + guard self.focusedTriggerIndex == nil else { return } + let updated = VoiceWakePreferences.loadTriggerWords() + if updated != self.triggerWords { + self.triggerWords = updated } } } @@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View { if self.triggerWords.isEmpty { self.triggerWords = VoiceWakePreferences.defaultTriggerWords } + self.commitTriggerWords() } private func binding(for index: Int) -> Binding { @@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View { self.triggerWords[index] = newValue }) } + + private func commitTriggerWords() { + VoiceWakePreferences.saveTriggerWords(self.triggerWords) + + let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords) + self.syncTask?.cancel() + self.syncTask = Task { [snapshot, weak appModel = self.appModel] in + try? await Task.sleep(nanoseconds: 650_000_000) + await appModel?.setGlobalWakeWords(snapshot) + } + } } diff --git a/apps/ios/Sources/Voice/VoiceWakePreferences.swift b/apps/ios/Sources/Voice/VoiceWakePreferences.swift index 96f46518e..4c75c22a6 100644 --- a/apps/ios/Sources/Voice/VoiceWakePreferences.swift +++ b/apps/ios/Sources/Voice/VoiceWakePreferences.swift @@ -6,6 +6,8 @@ enum VoiceWakePreferences { // Keep defaults aligned with the mac app. static let defaultTriggerWords: [String] = ["clawd", "claude"] + static let maxWords = 32 + static let maxWordLength = 64 static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { guard let data = payloadJSON.data(using: .utf8) else { return nil } @@ -30,6 +32,8 @@ enum VoiceWakePreferences { let cleaned = words .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + .prefix(Self.maxWords) + .map { String($0.prefix(Self.maxWordLength)) } return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned } diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index e0351f399..cbd68e6a3 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.1.21 + 2026.1.23 CFBundleVersion - 20260121 + 20260123 diff --git a/apps/ios/Tests/VoiceWakePreferencesTests.swift b/apps/ios/Tests/VoiceWakePreferencesTests.swift index acf501654..ec4a63afa 100644 --- a/apps/ios/Tests/VoiceWakePreferencesTests.swift +++ b/apps/ios/Tests/VoiceWakePreferencesTests.swift @@ -11,6 +11,18 @@ import Testing #expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords) } + @Test func sanitizeTriggerWordsLimitsWordLength() { + let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5) + let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long]) + #expect(cleaned[1].count == VoiceWakePreferences.maxWordLength) + } + + @Test func sanitizeTriggerWordsLimitsWordCount() { + let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" } + let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words) + #expect(cleaned.count == VoiceWakePreferences.maxWords) + } + @Test func displayStringUsesSanitizedWords() { #expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude") } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index ea6519001..a9b58617e 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -81,8 +81,8 @@ targets: properties: CFBundleDisplayName: Clawdbot CFBundleIconName: AppIcon - CFBundleShortVersionString: "2026.1.21" - CFBundleVersion: "20260121" + CFBundleShortVersionString: "2026.1.23" + CFBundleVersion: "20260123" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -130,5 +130,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: ClawdbotTests - CFBundleShortVersionString: "2026.1.21" - CFBundleVersion: "20260121" + CFBundleShortVersionString: "2026.1.23" + CFBundleVersion: "20260123" diff --git a/apps/macos/Sources/Clawdbot/Constants.swift b/apps/macos/Sources/Clawdbot/Constants.swift index 25f2589e3..b55bd6d20 100644 --- a/apps/macos/Sources/Clawdbot/Constants.swift +++ b/apps/macos/Sources/Clawdbot/Constants.swift @@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime" let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime" let showDockIconKey = "clawdbot.showDockIcon" let defaultVoiceWakeTriggers = ["clawd", "claude"] +let voiceWakeMaxWords = 32 +let voiceWakeMaxWordLength = 64 let voiceWakeMicKey = "clawdbot.voiceWakeMicID" let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName" let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID" diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist index 283901c0f..d5b40867a 100644 --- a/apps/macos/Sources/Clawdbot/Resources/Info.plist +++ b/apps/macos/Sources/Clawdbot/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.21 + 2026.1.23 CFBundleVersion - 202601210 + 202601230 CFBundleIconFile Clawdbot CFBundleURLTypes diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift b/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift index a60aa7d7c..98cdc0cb5 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift @@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] { let cleaned = words .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + .prefix(voiceWakeMaxWords) + .map { String($0.prefix(voiceWakeMaxWordLength)) } return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned } diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift index 176980cc5..a41e8bb1f 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift @@ -21,6 +21,7 @@ struct VoiceWakeSettings: View { @State private var micObserver = AudioInputDeviceObserver() @State private var micRefreshTask: Task? @State private var availableLocales: [Locale] = [] + @State private var triggerEntries: [TriggerEntry] = [] private let fieldLabelWidth: CGFloat = 140 private let controlWidth: CGFloat = 240 private let isPreview = ProcessInfo.processInfo.isPreview @@ -31,9 +32,9 @@ struct VoiceWakeSettings: View { var id: String { self.uid } } - private struct IndexedWord: Identifiable { - let id: Int - let value: String + private struct TriggerEntry: Identifiable { + let id: UUID + var value: String } private var voiceWakeBinding: Binding { @@ -105,6 +106,7 @@ struct VoiceWakeSettings: View { .onAppear { guard !self.isPreview else { return } self.startMicObserver() + self.loadTriggerEntries() } .onChange(of: self.state.voiceWakeMicID) { _, _ in guard !self.isPreview else { return } @@ -122,8 +124,10 @@ struct VoiceWakeSettings: View { self.micRefreshTask = nil Task { await self.meter.stop() } self.micObserver.stop() + self.syncTriggerEntriesToState() } else { self.startMicObserver() + self.loadTriggerEntries() } } .onDisappear { @@ -136,11 +140,16 @@ struct VoiceWakeSettings: View { self.micRefreshTask = nil self.micObserver.stop() Task { await self.meter.stop() } + self.syncTriggerEntriesToState() } } - private var indexedWords: [IndexedWord] { - self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } + private func loadTriggerEntries() { + self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) } + } + + private func syncTriggerEntriesToState() { + self.state.swabbleTriggerWords = self.triggerEntries.map(\.value) } private var triggerTable: some View { @@ -154,29 +163,42 @@ struct VoiceWakeSettings: View { } label: { Label("Add word", systemImage: "plus") } - .disabled(self.state.swabbleTriggerWords - .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) + .disabled(self.triggerEntries + .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) - Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } + Button("Reset defaults") { + self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) } + self.syncTriggerEntriesToState() + } } - Table(self.indexedWords) { - TableColumn("Word") { row in - TextField("Wake word", text: self.binding(for: row.id)) - .textFieldStyle(.roundedBorder) - } - TableColumn("") { row in - Button { - self.removeWord(at: row.id) - } label: { - Image(systemName: "trash") + VStack(spacing: 0) { + ForEach(self.$triggerEntries) { $entry in + HStack(spacing: 8) { + TextField("Wake word", text: $entry.value) + .textFieldStyle(.roundedBorder) + .onSubmit { + self.syncTriggerEntriesToState() + } + + Button { + self.removeWord(id: entry.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove trigger word") + .frame(width: 24) + } + .padding(8) + + if entry.id != self.triggerEntries.last?.id { + Divider() } - .buttonStyle(.borderless) - .help("Remove trigger word") } - .width(36) } - .frame(minHeight: 180) + .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) @@ -211,24 +233,12 @@ struct VoiceWakeSettings: View { } private func addWord() { - self.state.swabbleTriggerWords.append("") + self.triggerEntries.append(TriggerEntry(id: UUID(), value: "")) } - private func removeWord(at index: Int) { - guard self.state.swabbleTriggerWords.indices.contains(index) else { return } - self.state.swabbleTriggerWords.remove(at: index) - } - - private func binding(for index: Int) -> Binding { - Binding( - get: { - guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" } - return self.state.swabbleTriggerWords[index] - }, - set: { newValue in - guard self.state.swabbleTriggerWords.indices.contains(index) else { return } - self.state.swabbleTriggerWords[index] = newValue - }) + private func removeWord(id: UUID) { + self.triggerEntries.removeAll { $0.id == id } + self.syncTriggerEntriesToState() } private func toggleTest() { @@ -638,13 +648,14 @@ extension VoiceWakeSettings { state.voicePushToTalkEnabled = true state.swabbleTriggerWords = ["Claude", "Hey"] - let view = VoiceWakeSettings(state: state, isActive: true) + var view = VoiceWakeSettings(state: state, isActive: true) view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] view.availableLocales = [Locale(identifier: "en_US")] view.meterLevel = 0.42 view.meterError = "No input" view.testState = .detected("ok") view.isTesting = true + view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")] _ = view.body _ = view.localePicker @@ -654,8 +665,9 @@ extension VoiceWakeSettings { _ = view.chimeSection view.addWord() - _ = view.binding(for: 0).wrappedValue - view.removeWord(at: 0) + if let entryId = view.triggerEntries.first?.id { + view.removeWord(id: entryId) + } } } #endif diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift index e7f7e06fc..49ad5a124 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift @@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests { #expect(cleaned == defaultVoiceWakeTriggers) } + @Test func sanitizeTriggersLimitsWordLength() { + let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5) + let cleaned = sanitizeVoiceWakeTriggers(["ok", long]) + #expect(cleaned[1].count == voiceWakeMaxWordLength) + } + + @Test func sanitizeTriggersLimitsWordCount() { + let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" } + let cleaned = sanitizeVoiceWakeTriggers(words) + #expect(cleaned.count == voiceWakeMaxWords) + } + @Test func normalizeLocaleStripsCollation() { #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") } diff --git a/docs/cli/index.md b/docs/cli/index.md index 46f6d173e..fcc013fdc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -700,8 +700,15 @@ Options: - `--json` - `--plain` - `--check` (exit 1=expired/missing, 2=expiring) +- `--probe` (live probe of configured auth profiles) +- `--probe-provider ` +- `--probe-profile ` (repeat or comma-separated) +- `--probe-timeout ` +- `--probe-concurrency ` +- `--probe-max-tokens ` Always includes the auth overview and OAuth expiry status for profiles in the auth store. +`--probe` runs live requests (may consume tokens and trigger rate limits). ### `models set ` Set `agents.defaults.model.primary`. diff --git a/docs/cli/models.md b/docs/cli/models.md index f394a44f9..ba4600ce4 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -25,12 +25,26 @@ clawdbot models scan `clawdbot models status` shows the resolved default/fallbacks plus an auth overview. When provider usage snapshots are available, the OAuth/token status section includes provider usage headers. +Add `--probe` to run live auth probes against each configured provider profile. +Probes are real requests (may consume tokens and trigger rate limits). Notes: - `models set ` accepts `provider/model` or an alias. - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID). +### `models status` +Options: +- `--json` +- `--plain` +- `--check` (exit 1=expired/missing, 2=expiring) +- `--probe` (live probe of configured auth profiles) +- `--probe-provider ` (probe one provider) +- `--probe-profile ` (repeat or comma-separated profile ids) +- `--probe-timeout ` +- `--probe-concurrency ` +- `--probe-max-tokens ` + ## Aliases + fallbacks ```bash diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 1cef34b11..d87eb5f7f 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -43,6 +43,14 @@ Almost always a Node/npm PATH issue. Start here: - [Gateway troubleshooting](/gateway/troubleshooting) - [Control UI](/web/control-ui#insecure-http) +### `docs.clawd.bot` shows an SSL error (Comcast/Xfinity) + +Some Comcast/Xfinity connections block `docs.clawd.bot` via Xfinity Advanced Security. +Disable Advanced Security or add `docs.clawd.bot` to the allowlist, then retry. + +- Xfinity Advanced Security help: https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security +- Quick sanity checks: try a mobile hotspot or VPN to confirm it’s ISP-level filtering + ### Service says running, but RPC probe fails - [Gateway troubleshooting](/gateway/troubleshooting) diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 91b129b16..b4c7d5a84 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -30,17 +30,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.21 \ +APP_VERSION=2026.1.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip +ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg +scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.21 \ +APP_VERSION=2026.1.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip ``` ## Appcast entry Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. ## Publish & verify -- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`. +- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. diff --git a/docs/tui.md b/docs/tui.md index e67b22032..4d094dc6b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -88,6 +88,8 @@ Session lifecycle: - `/settings` - `/exit` +Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands). + ## Local shell commands - Prefix a line with `!` to run a local shell command on the TUI host. - The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index d511722a9..4385272be 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/bluebubbles", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot BlueBubbles channel plugin", "clawdbot": { diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index fa40e82a7..0f9973de9 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IncomingMessage, ServerResponse } from "node:http"; import { EventEmitter } from "node:events"; +import { removeAckReactionAfterReply, shouldAckReaction } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; import { handleBlueBubblesWebhookRequest, @@ -128,6 +129,7 @@ function createMockRuntime(): PluginRuntime { session: { resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], + recordInboundSession: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"], recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"], updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"], }, @@ -135,6 +137,10 @@ function createMockRuntime(): PluginRuntime { buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], }, + reactions: { + shouldAckReaction, + removeAckReactionAfterReply, + }, groups: { resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 81a921ca9..7c860d761 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,7 +1,13 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; -import { resolveAckReaction } from "clawdbot/plugin-sdk"; +import { + logAckFailure, + logInboundDrop, + logTypingFailure, + resolveAckReaction, + resolveControlCommandGate, +} from "clawdbot/plugin-sdk"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { downloadBlueBubblesAttachment } from "./attachments.js"; @@ -1346,23 +1352,25 @@ async function processMessage( }) : false; const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands; - const commandAuthorized = isGroup - ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, - { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, - ], - }) - : dmAuthorized; + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, + ], + allowTextCommands: true, + hasControlCommand: hasControlCmd, + }); + const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized; // Block control commands from unauthorized senders in groups - if (isGroup && hasControlCmd && !commandAuthorized) { - logVerbose( - core, - runtime, - `bluebubbles: drop control command from unauthorized sender ${message.senderId}`, - ); + if (isGroup && commandGate.shouldBlock) { + logInboundDrop({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + reason: "control command (unauthorized)", + target: message.senderId, + }); return; } @@ -1521,19 +1529,20 @@ async function processMessage( core, runtime, }); - const shouldAckReaction = () => { - if (!ackReactionValue) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return !isGroup; - if (ackReactionScope === "group-all") return isGroup; - if (ackReactionScope === "group-mentions") { - if (!isGroup) return false; - if (!requireMention) return false; - if (!canDetectMention) return false; - return effectiveWasMentioned; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReactionValue && + core.channel.reactions.shouldAckReaction({ + scope: ackReactionScope, + isDirect: !isGroup, + isGroup, + isMentionableGroup: isGroup, + requireMention: Boolean(requireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention, + }), + ); const ackMessageId = message.messageId?.trim() || ""; const ackReactionPromise = shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue @@ -1749,29 +1758,27 @@ async function processMessage( }, }); } finally { - if ( - removeAckAfterReply && - sentMessage && - ackReactionPromise && - ackReactionValue && - chatGuidForActions && - ackMessageId - ) { - void ackReactionPromise.then((didAck) => { - if (!didAck) return; - sendBlueBubblesReaction({ - chatGuid: chatGuidForActions, - messageGuid: ackMessageId, - emoji: ackReactionValue, - remove: true, - opts: { cfg: config, accountId: account.accountId }, - }).catch((err) => { - logVerbose( - core, - runtime, - `ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`, - ); - }); + if (sentMessage && chatGuidForActions && ackMessageId) { + core.channel.reactions.removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReactionValue ?? null, + remove: () => + sendBlueBubblesReaction({ + chatGuid: chatGuidForActions, + messageGuid: ackMessageId, + emoji: ackReactionValue ?? "", + remove: true, + opts: { cfg: config, accountId: account.accountId }, + }), + onError: (err) => { + logAckFailure({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + target: `${chatGuidForActions}/${ackMessageId}`, + error: err, + }); + }, }); } if (chatGuidForActions && baseUrl && password && !sentMessage) { @@ -1780,7 +1787,13 @@ async function processMessage( cfg: config, accountId: account.accountId, }).catch((err) => { - logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`); + logTypingFailure({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + action: "stop", + target: chatGuidForActions, + error: err, + }); }); } } diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 36b9e1403..02d1cdbdd 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/copilot-proxy", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Copilot Proxy provider plugin", "clawdbot": { diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index fd1b655a0..407ce60d1 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/diagnostics-otel", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot diagnostics OpenTelemetry exporter", "clawdbot": { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 8f43497a9..0a645718b 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/discord", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Discord channel plugin", "clawdbot": { diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json index c7626c272..ff3c485f2 100644 --- a/extensions/google-antigravity-auth/package.json +++ b/extensions/google-antigravity-auth/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/google-antigravity-auth", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Google Antigravity OAuth provider plugin", "clawdbot": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 9e9515f3e..f4b666ab0 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/google-gemini-cli-auth", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Gemini CLI OAuth provider plugin", "clawdbot": { diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 94b120b26..a3ac1c642 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/imessage", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot iMessage channel plugin", "clawdbot": { diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 2b4a5b2dd..ea774ecba 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/lobster", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "clawdbot": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 2d25c41c4..9f959843d 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index e5dc66a8c..1ba43d57e 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/matrix", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Matrix channel plugin", "clawdbot": { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 49deabbf8..2ba7cbef0 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,7 +1,12 @@ import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk"; import { + createReplyPrefixContext, + createTypingCallbacks, formatAllowlistMatchMeta, + logInboundDrop, + logTypingFailure, + resolveControlCommandGate, type RuntimeEnv, } from "clawdbot/plugin-sdk"; import type { CoreConfig, ReplyToMode } from "../../types.js"; @@ -376,21 +381,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam userName: senderName, }) : false; - const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg); + const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, { configured: groupAllowConfigured, allowed: senderAllowedForGroup }, ], + allowTextCommands, + hasControlCommand: hasControlCommandInMessage, }); - if ( - isRoom && - allowTextCommands && - core.channel.text.hasControlCommand(bodyText, cfg) && - !commandAuthorized - ) { - logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`); + const commandAuthorized = commandGate.commandAuthorized; + if (isRoom && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerboseMessage, + channel: "matrix", + reason: "control command (unauthorized)", + target: senderId, + }); return; } const shouldRequireMention = isRoom @@ -409,7 +418,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam !wasMentioned && !hasExplicitMention && commandAuthorized && - core.channel.text.hasControlCommand(bodyText); + hasControlCommandInMessage; + const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { logger.info({ roomId, reason: "no-mention" }, "skipping room message"); return; @@ -486,47 +496,45 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam OriginatingTo: `room:${roomId}`, }); - void core.channel.session - .recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - }) - .catch((err) => { + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "matrix", + to: `room:${roomId}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { logger.warn( { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, "failed updating session meta", ); - }); - - if (isDirectMessage) { - await core.channel.session.updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - channel: "matrix", - to: `room:${roomId}`, - accountId: route.accountId, - ctx: ctxPayload, - }); - } + }, + }); const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ackScope === "all") return true; - if (ackScope === "direct") return isDirectMessage; - if (ackScope === "group-all") return isRoom; - if (ackScope === "group-mentions") { - if (!isRoom) return false; - if (!shouldRequireMention) return false; - return wasMentioned || shouldBypassMention; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReaction && + core.channel.reactions.shouldAckReaction({ + scope: ackScope, + isDirect: isDirectMessage, + isGroup: isRoom, + isMentionableGroup: isRoom, + requireMention: Boolean(shouldRequireMention), + canDetectMention, + effectiveWasMentioned: wasMentioned || shouldBypassMention, + shouldBypassMention, + }), + ); if (shouldAckReaction() && messageId) { reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); @@ -553,10 +561,33 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam channel: "matrix", accountId: route.accountId, }); + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingMatrix(roomId, true, undefined, client), + stop: () => sendTypingMatrix(roomId, false, undefined, client), + onStartError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "start", + target: roomId, + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "stop", + target: roomId, + error: err, + }); + }, + }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) - .responsePrefix, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { await deliverMatrixReplies({ @@ -575,10 +606,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam onError: (err, info) => { runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: () => - sendTypingMatrix(roomId, true, undefined, client).catch(() => {}), - onIdle: () => - sendTypingMatrix(roomId, false, undefined, client).catch(() => {}), + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, }); const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ @@ -588,6 +617,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam replyOptions: { ...replyOptions, skillFilter: roomConfig?.skills, + onModelSelected: prefixContext.onModelSelected, }, }); markDispatchIdle(); diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index e704cedc5..251fe7b0b 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/mattermost", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Mattermost channel plugin", "clawdbot": { diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index cce05f0cb..659ca83aa 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -7,10 +7,15 @@ import type { RuntimeEnv, } from "clawdbot/plugin-sdk"; import { + createReplyPrefixContext, + createTypingCallbacks, + logInboundDrop, + logTypingFailure, buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, + resolveControlCommandGate, resolveChannelMediaMaxBytes, type HistoryEntry, } from "clawdbot/plugin-sdk"; @@ -30,12 +35,9 @@ import { } from "./client.js"; import { createDedupeCache, - extractShortModelName, formatInboundFromLabel, rawDataToString, - resolveIdentityName, resolveThreadSessionKeys, - type ResponsePrefixContext, } from "./monitor-helpers.js"; import { sendMessageMattermost } from "./send.js"; @@ -307,11 +309,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }; const sendTypingIndicator = async (channelId: string, parentId?: string) => { - try { - await sendMattermostTyping(client, { channelId, parentId }); - } catch (err) { - logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`); - } + await sendMattermostTyping(client, { channelId, parentId }); }; const resolveChannelInfo = async (channelId: string): Promise => { @@ -403,7 +401,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg, surface: "mattermost", }); - const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg); + const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); + const isControlCommand = allowTextCommands && hasControlCommand; const useAccessGroups = cfg.commands?.useAccessGroups !== false; const senderAllowedForCommands = isSenderAllowed({ senderId, @@ -415,19 +414,20 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderName, allowFrom: effectiveGroupAllowFrom, }); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { + configured: effectiveGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand, + }); const commandAuthorized = - kind === "dm" - ? dmPolicy === "open" || senderAllowedForCommands - : core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, - { - configured: effectiveGroupAllowFrom.length > 0, - allowed: groupAllowedForCommands, - }, - ], - }); + kind === "dm" ? dmPolicy === "open" || senderAllowedForCommands : commandGate.commandAuthorized; if (kind === "dm") { if (dmPolicy === "disabled") { @@ -488,10 +488,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } } - if (kind !== "dm" && isControlCommand && !commandAuthorized) { - logVerboseMessage( - `mattermost: drop control command from unauthorized sender ${senderId}`, - ); + if (kind !== "dm" && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerboseMessage, + channel: "mattermost", + reason: "control command (unauthorized)", + target: senderId, + }); return; } @@ -534,19 +537,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} : ""); const pendingSender = senderName; const recordPendingHistory = () => { - if (!historyKey || historyLimit <= 0) return; const trimmed = pendingBody.trim(); - if (!trimmed) return; - recordPendingHistoryEntry({ + recordPendingHistoryEntryIfEnabled({ historyMap: channelHistories, - historyKey, limit: historyLimit, - entry: { - sender: pendingSender, - body: trimmed, - timestamp: typeof post.create_at === "number" ? post.create_at : undefined, - messageId: post.id ?? undefined, - }, + historyKey: historyKey ?? "", + entry: historyKey && trimmed + ? { + sender: pendingSender, + body: trimmed, + timestamp: typeof post.create_at === "number" ? post.create_at : undefined, + messageId: post.id ?? undefined, + } + : null, }); }; @@ -623,7 +626,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} sender: { name: senderName, id: senderId }, }); let combinedBody = body; - if (historyKey && historyLimit > 0) { + if (historyKey) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: channelHistories, historyKey, @@ -713,15 +716,23 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} accountId: account.accountId, }); - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingIndicator(channelId, threadRootId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: channelId, + error: err, + }); + }, + }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) - .responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); @@ -752,7 +763,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} onError: (err, info) => { runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: () => sendTypingIndicator(channelId, threadRootId), + onReplyStart: typingCallbacks.onReplyStart, }); await core.channel.reply.dispatchReplyFromConfig({ @@ -763,17 +774,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ...replyOptions, disableBlockStreaming: typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, - onModelSelected: (ctx) => { - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; - }, + onModelSelected: prefixContext.onModelSelected, }, }); markDispatchIdle(); - if (historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: channelHistories, historyKey }); + if (historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit }); } }; diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 57dfc36ef..48a089aaa 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/memory-core", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot core memory search plugin", "clawdbot": { diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index bdaa21f35..4f0e97377 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/memory-lancedb", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "dependencies": { diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index aa132c382..63f54e309 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 5f7843f8a..80d566e7c 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/msteams", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Microsoft Teams channel plugin", "clawdbot": { diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index 0a44c50d6..cadb00dca 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] { host.endsWith("1drv.ms") || host.includes("sharepoint"); return looksLikeGraph - ? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"] - : ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"]; + ? ["https://graph.microsoft.com", "https://api.botframework.com"] + : ["https://api.botframework.com", "https://graph.microsoft.com"]; } catch { - return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"]; + return ["https://api.botframework.com", "https://graph.microsoft.com"]; } } diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index bb47d413f..6cad32e46 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -198,7 +198,7 @@ export async function downloadMSTeamsGraphMedia(params: { const messageUrl = params.messageUrl; let accessToken: string; try { - accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); + accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com"); } catch { return { media: [], messageUrl, tokenError: true }; } diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts index 35715acb4..bbc5c79eb 100644 --- a/extensions/msteams/src/directory-live.ts +++ b/extensions/msteams/src/directory-live.ts @@ -64,7 +64,7 @@ async function resolveGraphToken(cfg: unknown): Promise { if (!creds) throw new Error("MS Teams credentials missing"); const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); const tokenProvider = new sdk.MsalTokenProvider(authConfig); - const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); + const token = await tokenProvider.getAccessToken("https://graph.microsoft.com"); const accessToken = readAccessToken(token); if (!accessToken) throw new Error("MS Teams graph token unavailable"); return accessToken; diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts index dd4e28683..3bd9ea5a6 100644 --- a/extensions/msteams/src/graph-upload.ts +++ b/extensions/msteams/src/graph-upload.ts @@ -13,7 +13,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; const GRAPH_BETA = "https://graph.microsoft.com/beta"; -const GRAPH_SCOPE = "https://graph.microsoft.com/.default"; +const GRAPH_SCOPE = "https://graph.microsoft.com"; export interface OneDriveUploadResult { id: string; diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 1a0129180..16eb8fc0a 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -1,8 +1,10 @@ import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, - recordPendingHistoryEntry, + logInboundDrop, + recordPendingHistoryEntryIfEnabled, + resolveControlCommandGate, resolveMentionGating, formatAllowlistMatchMeta, type HistoryEntry, @@ -251,15 +253,24 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { senderId, senderName, }); - const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); + const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, }); - if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) { - logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`); + const commandAuthorized = commandGate.commandAuthorized; + if (commandGate.shouldBlock) { + logInboundDrop({ + log: logVerboseMessage, + channel: "msteams", + reason: "control command (unauthorized)", + target: senderId, + }); return; } @@ -371,19 +382,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { requireMention, mentioned, }); - if (historyLimit > 0) { - recordPendingHistoryEntry({ - historyMap: conversationHistories, - historyKey: conversationId, - limit: historyLimit, - entry: { - sender: senderName, - body: rawBody, - timestamp: timestamp?.getTime(), - messageId: activity.id ?? undefined, - }, - }); - } + recordPendingHistoryEntryIfEnabled({ + historyMap: conversationHistories, + historyKey: conversationId, + limit: historyLimit, + entry: { + sender: senderName, + body: rawBody, + timestamp: timestamp?.getTime(), + messageId: activity.id ?? undefined, + }, + }); return; } } @@ -426,7 +435,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { let combinedBody = body; const isRoomish = !isDirectMessage; const historyKey = isRoomish ? conversationId : undefined; - if (isRoomish && historyKey && historyLimit > 0) { + if (isRoomish && historyKey) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: conversationHistories, historyKey, @@ -467,12 +476,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ...mediaPayload, }); - void core.channel.session.recordSessionMetaFromInbound({ - storePath, + await core.channel.session.recordInboundSession({ + storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`); + onRecordError: (err) => { + logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`); + }, }); logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); @@ -512,10 +522,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const didSendReply = counts.final + counts.tool + counts.block > 0; if (!queuedFinal) { - if (isRoomish && historyKey && historyLimit > 0) { - clearHistoryEntries({ + if (isRoomish && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: conversationHistories, historyKey, + limit: historyLimit, }); } return; @@ -524,8 +535,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { logVerboseMessage( `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, ); - if (isRoomish && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: conversationHistories, historyKey }); + if (isRoomish && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: conversationHistories, + historyKey, + limit: historyLimit, + }); } } catch (err) { log.error("dispatch failed", { error: String(err) }); diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index f711c8240..449a14fe2 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -1,4 +1,7 @@ import { + createReplyPrefixContext, + createTypingCallbacks, + logTypingFailure, resolveChannelMediaMaxBytes, type ClawdbotConfig, type MSTeamsReplyStyle, @@ -39,23 +42,33 @@ export function createMSTeamsReplyDispatcher(params: { }) { const core = getMSTeamsRuntime(); const sendTypingIndicator = async () => { - try { - await params.context.sendActivities([{ type: "typing" }]); - } catch { - // Typing indicator is best-effort. - } + await params.context.sendActivities([{ type: "typing" }]); }; - - return core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig( - params.cfg, - params.agentId, - ).responsePrefix, - humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), - deliver: async (payload) => { - const tableMode = core.channel.text.resolveMarkdownTableMode({ - cfg: params.cfg, + const typingCallbacks = createTypingCallbacks({ + start: sendTypingIndicator, + onStartError: (err) => { + logTypingFailure({ + log: (message) => params.log.debug(message), channel: "msteams", + action: "start", + error: err, + }); + }, + }); + const prefixContext = createReplyPrefixContext({ + cfg: params.cfg, + agentId: params.agentId, + }); + + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + deliver: async (payload) => { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "msteams", }); const messages = renderReplyPayloadsToMessages([payload], { textChunkLimit: params.textLimit, @@ -87,21 +100,27 @@ export function createMSTeamsReplyDispatcher(params: { mediaMaxBytes, }); if (ids.length > 0) params.onSentMessageIds?.(ids); - }, - onError: (err, info) => { - const errMsg = formatUnknownError(err); - const classification = classifyMSTeamsSendError(err); - const hint = formatMSTeamsSendErrorHint(classification); - params.runtime.error?.( - `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`, - ); - params.log.error("reply failed", { - kind: info.kind, - error: errMsg, - classification, - hint, - }); - }, - onReplyStart: sendTypingIndicator, - }); + }, + onError: (err, info) => { + const errMsg = formatUnknownError(err); + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + params.runtime.error?.( + `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`, + ); + params.log.error("reply failed", { + kind: info.kind, + error: errMsg, + classification, + hint, + }); + }, + onReplyStart: typingCallbacks.onReplyStart, + }); + + return { + dispatcher, + replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected }, + markDispatchIdle, + }; } diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index a74c42f61..a5e7a0c74 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -143,7 +143,7 @@ async function resolveGraphToken(cfg: unknown): Promise { if (!creds) throw new Error("MS Teams credentials missing"); const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); const tokenProvider = new sdk.MsalTokenProvider(authConfig); - const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); + const token = await tokenProvider.getAccessToken("https://graph.microsoft.com"); const accessToken = readAccessToken(token); if (!accessToken) throw new Error("MS Teams graph token unavailable"); return accessToken; diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index c5ee0b8c6..5c6f5e243 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/nextcloud-talk", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Nextcloud Talk channel plugin", "clawdbot": { diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 1c6984848..77db9f338 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,4 +1,9 @@ -import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; +import { + logInboundDrop, + resolveControlCommandGate, + type ClawdbotConfig, + type RuntimeEnv, +} from "clawdbot/plugin-sdk"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { @@ -118,7 +123,11 @@ export async function handleNextcloudTalkInbound(params: { senderId, senderName, }).allowed; - const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + const hasControlCommand = core.channel.text.hasControlCommand( + rawBody, + config as ClawdbotConfig, + ); + const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ { @@ -127,7 +136,10 @@ export async function handleNextcloudTalkInbound(params: { allowed: senderAllowedForCommands, }, ], + allowTextCommands, + hasControlCommand, }); + const commandAuthorized = commandGate.commandAuthorized; if (isGroup) { const groupAllow = resolveNextcloudTalkGroupAllow({ @@ -188,15 +200,13 @@ export async function handleNextcloudTalkInbound(params: { } } - if ( - isGroup && - allowTextCommands && - core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) && - commandAuthorized !== true - ) { - runtime.log?.( - `nextcloud-talk: drop control command from unauthorized sender ${senderId}`, - ); + if (isGroup && commandGate.shouldBlock) { + logInboundDrop({ + log: (message) => runtime.log?.(message), + channel: CHANNEL_ID, + reason: "control command (unauthorized)", + target: senderId, + }); return; } @@ -212,10 +222,6 @@ export async function handleNextcloudTalkInbound(params: { wildcardConfig: roomMatch.wildcardConfig, }) : false; - const hasControlCommand = core.channel.text.hasControlCommand( - rawBody, - config as ClawdbotConfig, - ); const mentionGate = resolveNextcloudTalkMentionGate({ isGroup, requireMention: shouldRequireMention, @@ -287,15 +293,14 @@ export async function handleNextcloudTalkInbound(params: { CommandAuthorized: commandAuthorized, }); - void core.channel.session - .recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - }) - .catch((err) => { + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`); - }); + }, + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 2005c22b3..610f34e81 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index dc5a9e002..0efec2efa 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/nostr", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", "clawdbot": { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 73282f117..3fa6e8b17 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,9 +1,11 @@ { "name": "@clawdbot/open-prose", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "clawdbot": { - "extensions": ["./index.ts"] + "extensions": [ + "./index.ts" + ] } } diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 6c26e7774..89de33544 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/signal", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Signal channel plugin", "clawdbot": { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 93470c49d..f129515f5 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/slack", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Slack channel plugin", "clawdbot": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 2dc95db2e..e4005c739 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/telegram", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Telegram channel plugin", "clawdbot": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 08d0f5006..0edc0dcb8 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index ef0323ee4..88a0326b3 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/voice-call", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot voice-call plugin", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 49f0fb541..3dcc4cf6b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/whatsapp", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot WhatsApp channel plugin", "clawdbot": { diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index a5e56e1da..ab6d394fa 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 0f59602e7..7ced3106a 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/zalo", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Zalo channel plugin", "clawdbot": { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 939dcdbde..44a279354 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -570,12 +570,13 @@ async function processMessageWithPipeline(params: { OriginatingTo: `zalo:${chatId}`, }); - void core.channel.session.recordSessionMetaFromInbound({ + await core.channel.session.recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); + onRecordError: (err) => { + runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); + }, }); const tableMode = core.channel.text.resolveMarkdownTableMode({ diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index f5b7a3071..ec3c9e340 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 71e6da3cb..9f406c56c 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/zalouser", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Zalo Personal Account plugin via zca-cli", "dependencies": { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 4015fcc8d..97e5a4be3 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -311,12 +311,13 @@ async function processMessage( OriginatingTo: `zalouser:${chatId}`, }); - void core.channel.session.recordSessionMetaFromInbound({ + await core.channel.session.recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); + onRecordError: (err) => { + runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); + }, }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ diff --git a/package.json b/package.json index ae2aab470..f12d83a74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.22", + "version": "2026.1.23", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts new file mode 100644 index 000000000..1cfacda9a --- /dev/null +++ b/src/agents/compaction.test.ts @@ -0,0 +1,107 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { describe, expect, it } from "vitest"; + +import { + estimateMessagesTokens, + pruneHistoryForContextShare, + splitMessagesByTokenShare, +} from "./compaction.js"; + +function makeMessage(id: number, size: number): AgentMessage { + return { + role: "user", + content: "x".repeat(size), + timestamp: id, + }; +} + +describe("splitMessagesByTokenShare", () => { + it("splits messages into two non-empty parts", () => { + const messages: AgentMessage[] = [ + makeMessage(1, 4000), + makeMessage(2, 4000), + makeMessage(3, 4000), + makeMessage(4, 4000), + ]; + + const parts = splitMessagesByTokenShare(messages, 2); + expect(parts.length).toBeGreaterThanOrEqual(2); + expect(parts[0]?.length).toBeGreaterThan(0); + expect(parts[1]?.length).toBeGreaterThan(0); + expect(parts.flat().length).toBe(messages.length); + }); + + it("preserves message order across parts", () => { + const messages: AgentMessage[] = [ + makeMessage(1, 4000), + makeMessage(2, 4000), + makeMessage(3, 4000), + makeMessage(4, 4000), + makeMessage(5, 4000), + makeMessage(6, 4000), + ]; + + const parts = splitMessagesByTokenShare(messages, 3); + expect(parts.flat().map((msg) => msg.timestamp)).toEqual(messages.map((msg) => msg.timestamp)); + }); +}); + +describe("pruneHistoryForContextShare", () => { + it("drops older chunks until the history budget is met", () => { + const messages: AgentMessage[] = [ + makeMessage(1, 4000), + makeMessage(2, 4000), + makeMessage(3, 4000), + makeMessage(4, 4000), + ]; + const maxContextTokens = 2000; // budget is 1000 tokens (50%) + const pruned = pruneHistoryForContextShare({ + messages, + maxContextTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + + expect(pruned.droppedChunks).toBeGreaterThan(0); + expect(pruned.keptTokens).toBeLessThanOrEqual(Math.floor(maxContextTokens * 0.5)); + expect(pruned.messages.length).toBeGreaterThan(0); + }); + + it("keeps the newest messages when pruning", () => { + const messages: AgentMessage[] = [ + makeMessage(1, 4000), + makeMessage(2, 4000), + makeMessage(3, 4000), + makeMessage(4, 4000), + makeMessage(5, 4000), + makeMessage(6, 4000), + ]; + const totalTokens = estimateMessagesTokens(messages); + const maxContextTokens = Math.max(1, Math.floor(totalTokens * 0.5)); // budget = 25% + const pruned = pruneHistoryForContextShare({ + messages, + maxContextTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + + const keptIds = pruned.messages.map((msg) => msg.timestamp); + const expectedSuffix = messages.slice(-keptIds.length).map((msg) => msg.timestamp); + expect(keptIds).toEqual(expectedSuffix); + }); + + it("keeps history when already within budget", () => { + const messages: AgentMessage[] = [makeMessage(1, 1000)]; + const maxContextTokens = 2000; + const pruned = pruneHistoryForContextShare({ + messages, + maxContextTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + + expect(pruned.droppedChunks).toBe(0); + expect(pruned.messages.length).toBe(messages.length); + expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages)); + }); +}); diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts new file mode 100644 index 000000000..2ab4566fd --- /dev/null +++ b/src/agents/compaction.ts @@ -0,0 +1,341 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent"; + +import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; + +export const BASE_CHUNK_RATIO = 0.4; +export const MIN_CHUNK_RATIO = 0.15; +export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy +const DEFAULT_SUMMARY_FALLBACK = "No prior history."; +const DEFAULT_PARTS = 2; +const MERGE_SUMMARIES_INSTRUCTIONS = + "Merge these partial summaries into a single cohesive summary. Preserve decisions," + + " TODOs, open questions, and any constraints."; + +export function estimateMessagesTokens(messages: AgentMessage[]): number { + return messages.reduce((sum, message) => sum + estimateTokens(message), 0); +} + +function normalizeParts(parts: number, messageCount: number): number { + if (!Number.isFinite(parts) || parts <= 1) return 1; + return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount)); +} + +export function splitMessagesByTokenShare( + messages: AgentMessage[], + parts = DEFAULT_PARTS, +): AgentMessage[][] { + if (messages.length === 0) return []; + const normalizedParts = normalizeParts(parts, messages.length); + if (normalizedParts <= 1) return [messages]; + + const totalTokens = estimateMessagesTokens(messages); + const targetTokens = totalTokens / normalizedParts; + const chunks: AgentMessage[][] = []; + let current: AgentMessage[] = []; + let currentTokens = 0; + + for (const message of messages) { + const messageTokens = estimateTokens(message); + if ( + chunks.length < normalizedParts - 1 && + current.length > 0 && + currentTokens + messageTokens > targetTokens + ) { + chunks.push(current); + current = []; + currentTokens = 0; + } + + current.push(message); + currentTokens += messageTokens; + } + + if (current.length > 0) { + chunks.push(current); + } + + return chunks; +} + +export function chunkMessagesByMaxTokens( + messages: AgentMessage[], + maxTokens: number, +): AgentMessage[][] { + if (messages.length === 0) return []; + + const chunks: AgentMessage[][] = []; + let currentChunk: AgentMessage[] = []; + let currentTokens = 0; + + for (const message of messages) { + const messageTokens = estimateTokens(message); + if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) { + chunks.push(currentChunk); + currentChunk = []; + currentTokens = 0; + } + + currentChunk.push(message); + currentTokens += messageTokens; + + if (messageTokens > maxTokens) { + // Split oversized messages to avoid unbounded chunk growth. + chunks.push(currentChunk); + currentChunk = []; + currentTokens = 0; + } + } + + if (currentChunk.length > 0) { + chunks.push(currentChunk); + } + + return chunks; +} + +/** + * Compute adaptive chunk ratio based on average message size. + * When messages are large, we use smaller chunks to avoid exceeding model limits. + */ +export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number { + if (messages.length === 0) return BASE_CHUNK_RATIO; + + const totalTokens = estimateMessagesTokens(messages); + const avgTokens = totalTokens / messages.length; + + // Apply safety margin to account for estimation inaccuracy + const safeAvgTokens = avgTokens * SAFETY_MARGIN; + const avgRatio = safeAvgTokens / contextWindow; + + // If average message is > 10% of context, reduce chunk ratio + if (avgRatio > 0.1) { + const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO); + return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction); + } + + return BASE_CHUNK_RATIO; +} + +/** + * Check if a single message is too large to summarize. + * If single message > 50% of context, it can't be summarized safely. + */ +export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean { + const tokens = estimateTokens(msg) * SAFETY_MARGIN; + return tokens > contextWindow * 0.5; +} + +async function summarizeChunks(params: { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + customInstructions?: string; + previousSummary?: string; +}): Promise { + if (params.messages.length === 0) { + return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; + } + + const chunks = chunkMessagesByMaxTokens(params.messages, params.maxChunkTokens); + let summary = params.previousSummary; + + for (const chunk of chunks) { + summary = await generateSummary( + chunk, + params.model, + params.reserveTokens, + params.apiKey, + params.signal, + params.customInstructions, + summary, + ); + } + + return summary ?? DEFAULT_SUMMARY_FALLBACK; +} + +/** + * Summarize with progressive fallback for handling oversized messages. + * If full summarization fails, tries partial summarization excluding oversized messages. + */ +export async function summarizeWithFallback(params: { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + contextWindow: number; + customInstructions?: string; + previousSummary?: string; +}): Promise { + const { messages, contextWindow } = params; + + if (messages.length === 0) { + return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; + } + + // Try full summarization first + try { + return await summarizeChunks(params); + } catch (fullError) { + console.warn( + `Full summarization failed, trying partial: ${ + fullError instanceof Error ? fullError.message : String(fullError) + }`, + ); + } + + // Fallback 1: Summarize only small messages, note oversized ones + const smallMessages: AgentMessage[] = []; + const oversizedNotes: string[] = []; + + for (const msg of messages) { + if (isOversizedForSummary(msg, contextWindow)) { + const role = (msg as { role?: string }).role ?? "message"; + const tokens = estimateTokens(msg); + oversizedNotes.push( + `[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`, + ); + } else { + smallMessages.push(msg); + } + } + + if (smallMessages.length > 0) { + try { + const partialSummary = await summarizeChunks({ + ...params, + messages: smallMessages, + }); + const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; + return partialSummary + notes; + } catch (partialError) { + console.warn( + `Partial summarization also failed: ${ + partialError instanceof Error ? partialError.message : String(partialError) + }`, + ); + } + } + + // Final fallback: Just note what was there + return ( + `Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` + + `Summary unavailable due to size limits.` + ); +} + +export async function summarizeInStages(params: { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + contextWindow: number; + customInstructions?: string; + previousSummary?: string; + parts?: number; + minMessagesForSplit?: number; +}): Promise { + const { messages } = params; + if (messages.length === 0) { + return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; + } + + const minMessagesForSplit = Math.max(2, params.minMessagesForSplit ?? 4); + const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, messages.length); + const totalTokens = estimateMessagesTokens(messages); + + if (parts <= 1 || messages.length < minMessagesForSplit || totalTokens <= params.maxChunkTokens) { + return summarizeWithFallback(params); + } + + const splits = splitMessagesByTokenShare(messages, parts).filter((chunk) => chunk.length > 0); + if (splits.length <= 1) { + return summarizeWithFallback(params); + } + + const partialSummaries: string[] = []; + for (const chunk of splits) { + partialSummaries.push( + await summarizeWithFallback({ + ...params, + messages: chunk, + previousSummary: undefined, + }), + ); + } + + if (partialSummaries.length === 1) { + return partialSummaries[0]; + } + + const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({ + role: "user", + content: summary, + timestamp: Date.now(), + })); + + const mergeInstructions = params.customInstructions + ? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\nAdditional focus:\n${params.customInstructions}` + : MERGE_SUMMARIES_INSTRUCTIONS; + + return summarizeWithFallback({ + ...params, + messages: summaryMessages, + customInstructions: mergeInstructions, + }); +} + +export function pruneHistoryForContextShare(params: { + messages: AgentMessage[]; + maxContextTokens: number; + maxHistoryShare?: number; + parts?: number; +}): { + messages: AgentMessage[]; + droppedChunks: number; + droppedMessages: number; + droppedTokens: number; + keptTokens: number; + budgetTokens: number; +} { + const maxHistoryShare = params.maxHistoryShare ?? 0.5; + const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare)); + let keptMessages = params.messages; + let droppedChunks = 0; + let droppedMessages = 0; + let droppedTokens = 0; + + const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, keptMessages.length); + + while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) { + const chunks = splitMessagesByTokenShare(keptMessages, parts); + if (chunks.length <= 1) break; + const [dropped, ...rest] = chunks; + droppedChunks += 1; + droppedMessages += dropped.length; + droppedTokens += estimateMessagesTokens(dropped); + keptMessages = rest.flat(); + } + + return { + messages: keptMessages, + droppedChunks, + droppedMessages, + droppedTokens, + keptTokens: estimateMessagesTokens(keptMessages), + budgetTokens, + }; +} + +export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number { + return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS)); +} diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 0e3388b84..da33315f8 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -79,6 +79,7 @@ export async function runEmbeddedPiAgent( ? "markdown" : "plain" : "markdown"); + const isProbeSession = params.sessionId?.startsWith("probe-") ?? false; return enqueueCommandInLane(sessionLane, () => enqueueGlobal(async () => { @@ -455,7 +456,7 @@ export async function runEmbeddedPiAgent( cfg: params.config, agentDir: params.agentDir, }); - if (timedOut) { + if (timedOut && !isProbeSession) { log.warn( `Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`, ); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 093588cb3..74c405981 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -595,18 +595,23 @@ export async function runEmbeddedAttempt( setActiveEmbeddedRun(params.sessionId, queueHandle); let abortWarnTimer: NodeJS.Timeout | undefined; + const isProbeSession = params.sessionId?.startsWith("probe-") ?? false; const abortTimer = setTimeout( () => { - log.warn( - `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, - ); + if (!isProbeSession) { + log.warn( + `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, + ); + } abortRun(true); if (!abortWarnTimer) { abortWarnTimer = setTimeout(() => { if (!activeSession.isStreaming) return; - log.warn( - `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, - ); + if (!isProbeSession) { + log.warn( + `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, + ); + } }, 10_000); } }, diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index 4fcefca12..dcbe56244 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH state: "processing", reason: wasActive ? "run_replaced" : "run_started", }); - diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + if (!sessionId.startsWith("probe-")) { + diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + } } export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) { ACTIVE_EMBEDDED_RUNS.delete(sessionId); logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" }); - diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + if (!sessionId.startsWith("probe-")) { + diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + } notifyEmbeddedRunEnded(sessionId); } else { diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index a6a66637a..82ad19f2a 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -1,12 +1,16 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent"; -import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent"; - -import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; - -const BASE_CHUNK_RATIO = 0.4; -const MIN_CHUNK_RATIO = 0.15; -const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy +import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent"; +import { + BASE_CHUNK_RATIO, + MIN_CHUNK_RATIO, + SAFETY_MARGIN, + computeAdaptiveChunkRatio, + estimateMessagesTokens, + isOversizedForSummary, + pruneHistoryForContextShare, + resolveContextWindowTokens, + summarizeInStages, +} from "../compaction.js"; const FALLBACK_SUMMARY = "Summary unavailable due to context limits. Older messages were truncated."; const TURN_PREFIX_INSTRUCTIONS = @@ -129,175 +133,6 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str return `\n\n${sections.join("\n\n")}`; } -function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessage[][] { - if (messages.length === 0) return []; - - const chunks: AgentMessage[][] = []; - let currentChunk: AgentMessage[] = []; - let currentTokens = 0; - - for (const message of messages) { - const messageTokens = estimateTokens(message); - if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) { - chunks.push(currentChunk); - currentChunk = []; - currentTokens = 0; - } - - currentChunk.push(message); - currentTokens += messageTokens; - - if (messageTokens > maxTokens) { - // Split oversized messages to avoid unbounded chunk growth. - chunks.push(currentChunk); - currentChunk = []; - currentTokens = 0; - } - } - - if (currentChunk.length > 0) { - chunks.push(currentChunk); - } - - return chunks; -} - -/** - * Compute adaptive chunk ratio based on average message size. - * When messages are large, we use smaller chunks to avoid exceeding model limits. - */ -function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number { - if (messages.length === 0) return BASE_CHUNK_RATIO; - - const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0); - const avgTokens = totalTokens / messages.length; - - // Apply safety margin to account for estimation inaccuracy - const safeAvgTokens = avgTokens * SAFETY_MARGIN; - const avgRatio = safeAvgTokens / contextWindow; - - // If average message is > 10% of context, reduce chunk ratio - if (avgRatio > 0.1) { - const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO); - return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction); - } - - return BASE_CHUNK_RATIO; -} - -/** - * Check if a single message is too large to summarize. - * If single message > 50% of context, it can't be summarized safely. - */ -function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean { - const tokens = estimateTokens(msg) * SAFETY_MARGIN; - return tokens > contextWindow * 0.5; -} - -async function summarizeChunks(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - customInstructions?: string; - previousSummary?: string; -}): Promise { - if (params.messages.length === 0) { - return params.previousSummary ?? "No prior history."; - } - - const chunks = chunkMessages(params.messages, params.maxChunkTokens); - let summary = params.previousSummary; - - for (const chunk of chunks) { - summary = await generateSummary( - chunk, - params.model, - params.reserveTokens, - params.apiKey, - params.signal, - params.customInstructions, - summary, - ); - } - - return summary ?? "No prior history."; -} - -/** - * Summarize with progressive fallback for handling oversized messages. - * If full summarization fails, tries partial summarization excluding oversized messages. - */ -async function summarizeWithFallback(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - contextWindow: number; - customInstructions?: string; - previousSummary?: string; -}): Promise { - const { messages, contextWindow } = params; - - if (messages.length === 0) { - return params.previousSummary ?? "No prior history."; - } - - // Try full summarization first - try { - return await summarizeChunks(params); - } catch (fullError) { - console.warn( - `Full summarization failed, trying partial: ${ - fullError instanceof Error ? fullError.message : String(fullError) - }`, - ); - } - - // Fallback 1: Summarize only small messages, note oversized ones - const smallMessages: AgentMessage[] = []; - const oversizedNotes: string[] = []; - - for (const msg of messages) { - if (isOversizedForSummary(msg, contextWindow)) { - const role = (msg as { role?: string }).role ?? "message"; - const tokens = estimateTokens(msg); - oversizedNotes.push( - `[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`, - ); - } else { - smallMessages.push(msg); - } - } - - if (smallMessages.length > 0) { - try { - const partialSummary = await summarizeChunks({ - ...params, - messages: smallMessages, - }); - const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; - return partialSummary + notes; - } catch (partialError) { - console.warn( - `Partial summarization also failed: ${ - partialError instanceof Error ? partialError.message : String(partialError) - }`, - ); - } - } - - // Final fallback: Just note what was there - return ( - `Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` + - `Summary unavailable due to size limits.` - ); -} - export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { const { preparation, customInstructions, signal } = event; @@ -335,19 +170,48 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { } try { - const contextWindowTokens = Math.max( - 1, - Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS), - ); + const contextWindowTokens = resolveContextWindowTokens(model); + const turnPrefixMessages = preparation.turnPrefixMessages ?? []; + let messagesToSummarize = preparation.messagesToSummarize; + + const tokensBefore = + typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore) + ? preparation.tokensBefore + : undefined; + if (tokensBefore !== undefined) { + const summarizableTokens = + estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages); + const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens)); + const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5); + + if (newContentTokens > maxHistoryTokens) { + const pruned = pruneHistoryForContextShare({ + messages: messagesToSummarize, + maxContextTokens: contextWindowTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + if (pruned.droppedChunks > 0) { + const newContentRatio = (newContentTokens / contextWindowTokens) * 100; + console.warn( + `Compaction safeguard: new content uses ${newContentRatio.toFixed( + 1, + )}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` + + `(${pruned.droppedMessages} messages) to fit history budget.`, + ); + messagesToSummarize = pruned.messages; + } + } + } // Use adaptive chunk ratio based on message sizes - const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages]; + const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens); const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio)); const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens)); - const historySummary = await summarizeWithFallback({ - messages: preparation.messagesToSummarize, + const historySummary = await summarizeInStages({ + messages: messagesToSummarize, model, apiKey, signal, @@ -359,9 +223,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { }); let summary = historySummary; - if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) { - const prefixSummary = await summarizeWithFallback({ - messages: preparation.turnPrefixMessages, + if (preparation.isSplitTurn && turnPrefixMessages.length > 0) { + const prefixSummary = await summarizeInStages({ + messages: turnPrefixMessages, model, apiKey, signal, @@ -369,6 +233,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { maxChunkTokens, contextWindow: contextWindowTokens, customInstructions: TURN_PREFIX_INSTRUCTIONS, + previousSummary: undefined, }); summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`; } diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts new file mode 100644 index 000000000..8b1626c3d --- /dev/null +++ b/src/auto-reply/dispatch.ts @@ -0,0 +1,77 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { FinalizedMsgContext, MsgContext } from "./templating.js"; +import type { GetReplyOptions } from "./types.js"; +import { finalizeInboundContext } from "./reply/inbound-context.js"; +import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.js"; +import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js"; +import { + createReplyDispatcher, + createReplyDispatcherWithTyping, + type ReplyDispatcher, + type ReplyDispatcherOptions, + type ReplyDispatcherWithTypingOptions, +} from "./reply/reply-dispatcher.js"; + +export type DispatchInboundResult = DispatchFromConfigResult; + +export async function dispatchInboundMessage(params: { + ctx: MsgContext | FinalizedMsgContext; + cfg: ClawdbotConfig; + dispatcher: ReplyDispatcher; + replyOptions?: Omit; + replyResolver?: typeof import("./reply.js").getReplyFromConfig; +}): Promise { + const finalized = finalizeInboundContext(params.ctx); + return await dispatchReplyFromConfig({ + ctx: finalized, + cfg: params.cfg, + dispatcher: params.dispatcher, + replyOptions: params.replyOptions, + replyResolver: params.replyResolver, + }); +} + +export async function dispatchInboundMessageWithBufferedDispatcher(params: { + ctx: MsgContext | FinalizedMsgContext; + cfg: ClawdbotConfig; + dispatcherOptions: ReplyDispatcherWithTypingOptions; + replyOptions?: Omit; + replyResolver?: typeof import("./reply.js").getReplyFromConfig; +}): Promise { + const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping( + params.dispatcherOptions, + ); + + const result = await dispatchInboundMessage({ + ctx: params.ctx, + cfg: params.cfg, + dispatcher, + replyResolver: params.replyResolver, + replyOptions: { + ...params.replyOptions, + ...replyOptions, + }, + }); + + markDispatchIdle(); + return result; +} + +export async function dispatchInboundMessageWithDispatcher(params: { + ctx: MsgContext | FinalizedMsgContext; + cfg: ClawdbotConfig; + dispatcherOptions: ReplyDispatcherOptions; + replyOptions?: Omit; + replyResolver?: typeof import("./reply.js").getReplyFromConfig; +}): Promise { + const dispatcher = createReplyDispatcher(params.dispatcherOptions); + const result = await dispatchInboundMessage({ + ctx: params.ctx, + cfg: params.cfg, + dispatcher, + replyResolver: params.replyResolver, + replyOptions: params.replyOptions, + }); + await dispatcher.waitForIdle(); + return result; +} diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 0e7dfa233..532bac00a 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -82,7 +82,8 @@ export async function runAgentTurnWithFallback(params: { // Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates. const directlySentBlockKeys = new Set(); - const runId = crypto.randomUUID(); + const runId = params.opts?.runId ?? crypto.randomUUID(); + params.opts?.onAgentRunStart?.(runId); if (params.sessionKey) { registerAgentRunContext(runId, { sessionKey: params.sessionKey, @@ -174,6 +175,7 @@ export async function runAgentTurnWithFallback(params: { extraSystemPrompt: params.followupRun.run.extraSystemPrompt, ownerNumbers: params.followupRun.run.ownerNumbers, cliSessionId, + images: params.opts?.images, }) .then((result) => { emitAgentEvent({ @@ -248,6 +250,8 @@ export async function runAgentTurnWithFallback(params: { bashElevated: params.followupRun.run.bashElevated, timeoutMs: params.followupRun.run.timeoutMs, runId, + images: params.opts?.images, + abortSignal: params.opts?.abortSignal, blockReplyBreak: params.resolvedBlockStreamingBreak, blockReplyChunking: params.blockReplyChunking, onPartialReply: allowPartialStream diff --git a/src/auto-reply/reply/history.test.ts b/src/auto-reply/reply/history.test.ts index addbf5860..7991731da 100644 --- a/src/auto-reply/reply/history.test.ts +++ b/src/auto-reply/reply/history.test.ts @@ -5,7 +5,9 @@ import { buildHistoryContextFromEntries, buildHistoryContextFromMap, buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, HISTORY_CONTEXT_MARKER, + recordPendingHistoryEntryIfEnabled, } from "./history.js"; import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; @@ -105,4 +107,46 @@ describe("history helpers", () => { expect(result).toContain(CURRENT_MESSAGE_MARKER); expect(result).toContain("current"); }); + + it("records pending entries only when enabled", () => { + const historyMap = new Map(); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 0, + entry: { sender: "A", body: "one" }, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: null, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "B", body: "two" }, + }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]); + }); + + it("clears history entries only when enabled", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 }); + expect(historyMap.get("group")).toEqual([]); + }); }); diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts index b51e27da4..bc59b4f2e 100644 --- a/src/auto-reply/reply/history.ts +++ b/src/auto-reply/reply/history.ts @@ -47,6 +47,22 @@ export function recordPendingHistoryEntry(params: { return appendHistoryEntry(params); } +export function recordPendingHistoryEntryIfEnabled(params: { + historyMap: Map; + historyKey: string; + entry?: T | null; + limit: number; +}): T[] { + if (!params.entry) return []; + if (params.limit <= 0) return []; + return recordPendingHistoryEntry({ + historyMap: params.historyMap, + historyKey: params.historyKey, + entry: params.entry, + limit: params.limit, + }); +} + export function buildPendingHistoryContextFromMap(params: { historyMap: Map; historyKey: string; @@ -101,6 +117,15 @@ export function clearHistoryEntries(params: { params.historyMap.set(params.historyKey, []); } +export function clearHistoryEntriesIfEnabled(params: { + historyMap: Map; + historyKey: string; + limit: number; +}): void { + if (params.limit <= 0) return; + clearHistoryEntries({ historyMap: params.historyMap, historyKey: params.historyKey }); +} + export function buildHistoryContextFromEntries(params: { entries: HistoryEntry[]; currentMessage: string; diff --git a/src/auto-reply/reply/provider-dispatcher.ts b/src/auto-reply/reply/provider-dispatcher.ts index 68e2431d1..e4766156e 100644 --- a/src/auto-reply/reply/provider-dispatcher.ts +++ b/src/auto-reply/reply/provider-dispatcher.ts @@ -1,58 +1,44 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import type { FinalizedMsgContext } from "../templating.js"; +import type { FinalizedMsgContext, MsgContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; -import type { DispatchFromConfigResult } from "./dispatch-from-config.js"; -import { dispatchReplyFromConfig } from "./dispatch-from-config.js"; +import type { DispatchInboundResult } from "../dispatch.js"; import { - createReplyDispatcher, - createReplyDispatcherWithTyping, - type ReplyDispatcherOptions, - type ReplyDispatcherWithTypingOptions, + dispatchInboundMessageWithBufferedDispatcher, + dispatchInboundMessageWithDispatcher, +} from "../dispatch.js"; +import type { + ReplyDispatcherOptions, + ReplyDispatcherWithTypingOptions, } from "./reply-dispatcher.js"; export async function dispatchReplyWithBufferedBlockDispatcher(params: { - ctx: FinalizedMsgContext; + ctx: MsgContext | FinalizedMsgContext; cfg: ClawdbotConfig; dispatcherOptions: ReplyDispatcherWithTypingOptions; replyOptions?: Omit; replyResolver?: typeof import("../reply.js").getReplyFromConfig; -}): Promise { - const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping( - params.dispatcherOptions, - ); - - const result = await dispatchReplyFromConfig({ +}): Promise { + return await dispatchInboundMessageWithBufferedDispatcher({ ctx: params.ctx, cfg: params.cfg, - dispatcher, + dispatcherOptions: params.dispatcherOptions, replyResolver: params.replyResolver, - replyOptions: { - ...params.replyOptions, - ...replyOptions, - }, + replyOptions: params.replyOptions, }); - - markDispatchIdle(); - return result; } export async function dispatchReplyWithDispatcher(params: { - ctx: FinalizedMsgContext; + ctx: MsgContext | FinalizedMsgContext; cfg: ClawdbotConfig; dispatcherOptions: ReplyDispatcherOptions; replyOptions?: Omit; replyResolver?: typeof import("../reply.js").getReplyFromConfig; -}): Promise { - const dispatcher = createReplyDispatcher(params.dispatcherOptions); - - const result = await dispatchReplyFromConfig({ +}): Promise { + return await dispatchInboundMessageWithDispatcher({ ctx: params.ctx, cfg: params.cfg, - dispatcher, + dispatcherOptions: params.dispatcherOptions, replyResolver: params.replyResolver, replyOptions: params.replyOptions, }); - - await dispatcher.waitForIdle(); - return result; } diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index e1bf611db..250c14091 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -1,3 +1,4 @@ +import type { ImageContent } from "@mariozechner/pi-ai"; import type { TypingController } from "./reply/typing.js"; export type BlockReplyContext = { @@ -13,6 +14,14 @@ export type ModelSelectedContext = { }; export type GetReplyOptions = { + /** Override run id for agent events (defaults to random UUID). */ + runId?: string; + /** Abort signal for the underlying agent run. */ + abortSignal?: AbortSignal; + /** Optional inbound images (used for webchat attachments). */ + images?: ImageContent[]; + /** Notifies when an agent run actually starts (useful for webchat command handling). */ + onAgentRunStart?: (runId: string) => void; onReplyStart?: () => Promise | void; onTypingController?: (typing: TypingController) => void; isHeartbeat?: boolean; diff --git a/src/channels/ack-reactions.test.ts b/src/channels/ack-reactions.test.ts new file mode 100644 index 000000000..ed018ba5a --- /dev/null +++ b/src/channels/ack-reactions.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + removeAckReactionAfterReply, + shouldAckReaction, + shouldAckReactionForWhatsApp, +} from "./ack-reactions.js"; + +describe("shouldAckReaction", () => { + it("honors direct and group-all scopes", () => { + expect( + shouldAckReaction({ + scope: "direct", + isDirect: true, + isGroup: false, + isMentionableGroup: false, + requireMention: false, + canDetectMention: false, + effectiveWasMentioned: false, + }), + ).toBe(true); + + expect( + shouldAckReaction({ + scope: "group-all", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: false, + canDetectMention: false, + effectiveWasMentioned: false, + }), + ).toBe(true); + }); + + it("skips when scope is off or none", () => { + expect( + shouldAckReaction({ + scope: "off", + isDirect: true, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(false); + + expect( + shouldAckReaction({ + scope: "none", + isDirect: true, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(false); + }); + + it("defaults to group-mentions gating", () => { + expect( + shouldAckReaction({ + scope: undefined, + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(true); + }); + + it("requires mention gating for group-mentions", () => { + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: false, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(false); + + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: false, + effectiveWasMentioned: true, + }), + ).toBe(false); + + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: false, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(false); + + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(true); + + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: false, + shouldBypassMention: true, + }), + ).toBe(true); + }); +}); + +describe("shouldAckReactionForWhatsApp", () => { + it("respects direct and group modes", () => { + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: true, + isGroup: false, + directEnabled: true, + groupMode: "mentions", + wasMentioned: false, + groupActivated: false, + }), + ).toBe(true); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: true, + isGroup: false, + directEnabled: false, + groupMode: "mentions", + wasMentioned: false, + groupActivated: false, + }), + ).toBe(false); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "always", + wasMentioned: false, + groupActivated: false, + }), + ).toBe(true); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "never", + wasMentioned: true, + groupActivated: true, + }), + ).toBe(false); + }); + + it("honors mentions or activation for group-mentions", () => { + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "mentions", + wasMentioned: true, + groupActivated: false, + }), + ).toBe(true); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "mentions", + wasMentioned: false, + groupActivated: true, + }), + ).toBe(true); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "mentions", + wasMentioned: false, + groupActivated: false, + }), + ).toBe(false); + }); +}); + +describe("removeAckReactionAfterReply", () => { + it("removes only when ack succeeded", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + const onError = vi.fn(); + removeAckReactionAfterReply({ + removeAfterReply: true, + ackReactionPromise: Promise.resolve(true), + ackReactionValue: "👀", + remove, + onError, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(remove).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it("skips removal when ack did not happen", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + removeAckReactionAfterReply({ + removeAfterReply: true, + ackReactionPromise: Promise.resolve(false), + ackReactionValue: "👀", + remove, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(remove).not.toHaveBeenCalled(); + }); + + it("skips when not configured", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + removeAckReactionAfterReply({ + removeAfterReply: false, + ackReactionPromise: Promise.resolve(true), + ackReactionValue: "👀", + remove, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(remove).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/ack-reactions.ts b/src/channels/ack-reactions.ts new file mode 100644 index 000000000..f35ae76d8 --- /dev/null +++ b/src/channels/ack-reactions.ts @@ -0,0 +1,71 @@ +export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none"; + +export type WhatsAppAckReactionMode = "always" | "mentions" | "never"; + +export type AckReactionGateParams = { + scope: AckReactionScope | undefined; + isDirect: boolean; + isGroup: boolean; + isMentionableGroup: boolean; + requireMention: boolean; + canDetectMention: boolean; + effectiveWasMentioned: boolean; + shouldBypassMention?: boolean; +}; + +export function shouldAckReaction(params: AckReactionGateParams): boolean { + const scope = params.scope ?? "group-mentions"; + if (scope === "off" || scope === "none") return false; + if (scope === "all") return true; + if (scope === "direct") return params.isDirect; + if (scope === "group-all") return params.isGroup; + if (scope === "group-mentions") { + if (!params.isMentionableGroup) return false; + if (!params.requireMention) return false; + if (!params.canDetectMention) return false; + return params.effectiveWasMentioned || params.shouldBypassMention === true; + } + return false; +} + +export function shouldAckReactionForWhatsApp(params: { + emoji: string; + isDirect: boolean; + isGroup: boolean; + directEnabled: boolean; + groupMode: WhatsAppAckReactionMode; + wasMentioned: boolean; + groupActivated: boolean; +}): boolean { + if (!params.emoji) return false; + if (params.isDirect) return params.directEnabled; + if (!params.isGroup) return false; + if (params.groupMode === "never") return false; + if (params.groupMode === "always") return true; + return shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: params.wasMentioned, + shouldBypassMention: params.groupActivated, + }); +} + +export function removeAckReactionAfterReply(params: { + removeAfterReply: boolean; + ackReactionPromise: Promise | null; + ackReactionValue: string | null; + remove: () => Promise; + onError?: (err: unknown) => void; +}) { + if (!params.removeAfterReply) return; + if (!params.ackReactionPromise) return; + if (!params.ackReactionValue) return; + void params.ackReactionPromise.then((didAck) => { + if (!didAck) return; + params.remove().catch((err) => params.onError?.(err)); + }); +} diff --git a/src/channels/logging.ts b/src/channels/logging.ts new file mode 100644 index 000000000..0e124a14d --- /dev/null +++ b/src/channels/logging.ts @@ -0,0 +1,33 @@ +export type LogFn = (message: string) => void; + +export function logInboundDrop(params: { + log: LogFn; + channel: string; + reason: string; + target?: string; +}): void { + const target = params.target ? ` target=${params.target}` : ""; + params.log(`${params.channel}: drop ${params.reason}${target}`); +} + +export function logTypingFailure(params: { + log: LogFn; + channel: string; + target?: string; + action?: "start" | "stop"; + error: unknown; +}): void { + const target = params.target ? ` target=${params.target}` : ""; + const action = params.action ? ` action=${params.action}` : ""; + params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`); +} + +export function logAckFailure(params: { + log: LogFn; + channel: string; + target?: string; + error: unknown; +}): void { + const target = params.target ? ` target=${params.target}` : ""; + params.log(`${params.channel} ack cleanup failed${target}: ${String(params.error)}`); +} diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts new file mode 100644 index 000000000..4897426d0 --- /dev/null +++ b/src/channels/reply-prefix.ts @@ -0,0 +1,41 @@ +import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { GetReplyOptions } from "../auto-reply/types.js"; +import { + extractShortModelName, + type ResponsePrefixContext, +} from "../auto-reply/reply/response-prefix-template.js"; + +type ModelSelectionContext = Parameters>[0]; + +export type ReplyPrefixContextBundle = { + prefixContext: ResponsePrefixContext; + responsePrefix?: string; + responsePrefixContextProvider: () => ResponsePrefixContext; + onModelSelected: (ctx: ModelSelectionContext) => void; +}; + +export function createReplyPrefixContext(params: { + cfg: ClawdbotConfig; + agentId: string; +}): ReplyPrefixContextBundle { + const { cfg, agentId } = params; + const prefixContext: ResponsePrefixContext = { + identityName: resolveIdentityName(cfg, agentId), + }; + + const onModelSelected = (ctx: ModelSelectionContext) => { + // Mutate the object directly instead of reassigning to ensure closures see updates. + prefixContext.provider = ctx.provider; + prefixContext.model = extractShortModelName(ctx.model); + prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; + prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + }; + + return { + prefixContext, + responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix, + responsePrefixContextProvider: () => prefixContext, + onModelSelected, + }; +} diff --git a/src/channels/session.ts b/src/channels/session.ts new file mode 100644 index 000000000..2d34d7f74 --- /dev/null +++ b/src/channels/session.ts @@ -0,0 +1,49 @@ +import type { MsgContext } from "../auto-reply/templating.js"; +import { + recordSessionMetaFromInbound, + type GroupKeyResolution, + type SessionEntry, + updateLastRoute, +} from "../config/sessions.js"; + +export type InboundLastRouteUpdate = { + sessionKey: string; + channel: SessionEntry["lastChannel"]; + to: string; + accountId?: string; + threadId?: string | number; +}; + +export async function recordInboundSession(params: { + storePath: string; + sessionKey: string; + ctx: MsgContext; + groupResolution?: GroupKeyResolution | null; + createIfMissing?: boolean; + updateLastRoute?: InboundLastRouteUpdate; + onRecordError: (err: unknown) => void; +}): Promise { + const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params; + void recordSessionMetaFromInbound({ + storePath, + sessionKey, + ctx, + groupResolution, + createIfMissing, + }).catch(params.onRecordError); + + const update = params.updateLastRoute; + if (!update) return; + await updateLastRoute({ + storePath, + sessionKey: update.sessionKey, + deliveryContext: { + channel: update.channel, + to: update.to, + accountId: update.accountId, + threadId: update.threadId, + }, + ctx, + groupResolution, + }); +} diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts new file mode 100644 index 000000000..42080b3c1 --- /dev/null +++ b/src/channels/typing.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTypingCallbacks } from "./typing.js"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("createTypingCallbacks", () => { + it("invokes start on reply start", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(start).toHaveBeenCalledTimes(1); + expect(onStartError).not.toHaveBeenCalled(); + }); + + it("reports start errors", async () => { + const start = vi.fn().mockRejectedValue(new Error("fail")); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(onStartError).toHaveBeenCalledTimes(1); + }); + + it("invokes stop on idle and reports stop errors", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockRejectedValue(new Error("stop")); + const onStartError = vi.fn(); + const onStopError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); + + callbacks.onIdle?.(); + await flush(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(onStopError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/channels/typing.ts b/src/channels/typing.ts new file mode 100644 index 000000000..f24c7f188 --- /dev/null +++ b/src/channels/typing.ts @@ -0,0 +1,28 @@ +export type TypingCallbacks = { + onReplyStart: () => Promise; + onIdle?: () => void; +}; + +export function createTypingCallbacks(params: { + start: () => Promise; + stop?: () => Promise; + onStartError: (err: unknown) => void; + onStopError?: (err: unknown) => void; +}): TypingCallbacks { + const stop = params.stop; + const onReplyStart = async () => { + try { + await params.start(); + } catch (err) { + params.onStartError(err); + } + }; + + const onIdle = stop + ? () => { + void stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); + } + : undefined; + + return { onReplyStart, onIdle }; +} diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index a2674d94a..20a476f81 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -71,9 +71,36 @@ export function registerModelsCli(program: Command) { "Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)", false, ) + .option("--probe", "Probe configured provider auth (live)", false) + .option("--probe-provider ", "Only probe a single provider") + .option( + "--probe-profile ", + "Only probe specific auth profile ids (repeat or comma-separated)", + (value, previous) => { + const next = Array.isArray(previous) ? previous : previous ? [previous] : []; + next.push(value); + return next; + }, + ) + .option("--probe-timeout ", "Per-probe timeout in ms") + .option("--probe-concurrency ", "Concurrent probes") + .option("--probe-max-tokens ", "Probe max tokens (best-effort)") .action(async (opts) => { await runModelsCommand(async () => { - await modelsStatusCommand(opts, defaultRuntime); + await modelsStatusCommand( + { + json: Boolean(opts.json), + plain: Boolean(opts.plain), + check: Boolean(opts.check), + probe: Boolean(opts.probe), + probeProvider: opts.probeProvider as string | undefined, + probeProfile: opts.probeProfile as string | string[] | undefined, + probeTimeout: opts.probeTimeout as string | undefined, + probeConcurrency: opts.probeConcurrency as string | undefined, + probeMaxTokens: opts.probeMaxTokens as string | undefined, + }, + defaultRuntime, + ); }); }); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 47ebfe2f5..850f27246 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -17,6 +17,7 @@ const discoverModels = vi.fn(); vi.mock("../config/config.js", () => ({ CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json", + STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state", loadConfig, })); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts new file mode 100644 index 000000000..fbd172b57 --- /dev/null +++ b/src/commands/models/list.probe.ts @@ -0,0 +1,414 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; + +import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, + resolveAuthProfileDisplayLabel, +} from "../../agents/auth-profiles.js"; +import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { describeFailoverError } from "../../agents/failover-error.js"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; +import { normalizeProviderId, parseModelRef } from "../../agents/model-selection.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { + resolveSessionTranscriptPath, + resolveSessionTranscriptsDirForAgent, +} from "../../config/sessions/paths.js"; +import { redactSecrets } from "../status-all/format.js"; +import { DEFAULT_PROVIDER, formatMs } from "./shared.js"; + +const PROBE_PROMPT = "Reply with OK. Do not use tools."; + +export type AuthProbeStatus = + | "ok" + | "auth" + | "rate_limit" + | "billing" + | "timeout" + | "format" + | "unknown" + | "no_model"; + +export type AuthProbeResult = { + provider: string; + model?: string; + profileId?: string; + label: string; + source: "profile" | "env" | "models.json"; + mode?: string; + status: AuthProbeStatus; + error?: string; + latencyMs?: number; +}; + +type AuthProbeTarget = { + provider: string; + model?: { provider: string; model: string } | null; + profileId?: string; + label: string; + source: "profile" | "env" | "models.json"; + mode?: string; +}; + +export type AuthProbeSummary = { + startedAt: number; + finishedAt: number; + durationMs: number; + totalTargets: number; + options: { + provider?: string; + profileIds?: string[]; + timeoutMs: number; + concurrency: number; + maxTokens: number; + }; + results: AuthProbeResult[]; +}; + +export type AuthProbeOptions = { + provider?: string; + profileIds?: string[]; + timeoutMs: number; + concurrency: number; + maxTokens: number; +}; + +const toStatus = (reason?: string | null): AuthProbeStatus => { + if (!reason) return "unknown"; + if (reason === "auth") return "auth"; + if (reason === "rate_limit") return "rate_limit"; + if (reason === "billing") return "billing"; + if (reason === "timeout") return "timeout"; + if (reason === "format") return "format"; + return "unknown"; +}; + +function buildCandidateMap(modelCandidates: string[]): Map { + const map = new Map(); + for (const raw of modelCandidates) { + const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); + if (!parsed) continue; + const list = map.get(parsed.provider) ?? []; + if (!list.includes(parsed.model)) list.push(parsed.model); + map.set(parsed.provider, list); + } + return map; +} + +function selectProbeModel(params: { + provider: string; + candidates: Map; + catalog: Array<{ provider: string; id: string }>; +}): { provider: string; model: string } | null { + const { provider, candidates, catalog } = params; + const direct = candidates.get(provider); + if (direct && direct.length > 0) { + return { provider, model: direct[0] }; + } + const fromCatalog = catalog.find((entry) => entry.provider === provider); + if (fromCatalog) return { provider: fromCatalog.provider, model: fromCatalog.id }; + return null; +} + +function buildProbeTargets(params: { + cfg: ClawdbotConfig; + providers: string[]; + modelCandidates: string[]; + options: AuthProbeOptions; +}): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> { + const { cfg, providers, modelCandidates, options } = params; + const store = ensureAuthProfileStore(); + const providerFilter = options.provider?.trim(); + const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null; + const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean)); + + return loadModelCatalog({ config: cfg }).then((catalog) => { + const candidates = buildCandidateMap(modelCandidates); + const targets: AuthProbeTarget[] = []; + const results: AuthProbeResult[] = []; + + for (const provider of providers) { + const providerKey = normalizeProviderId(provider); + if (providerFilterKey && providerKey !== providerFilterKey) continue; + + const model = selectProbeModel({ + provider: providerKey, + candidates, + catalog, + }); + + const profileIds = listProfilesForProvider(store, providerKey); + const filteredProfiles = profileFilter.size + ? profileIds.filter((id) => profileFilter.has(id)) + : profileIds; + + if (filteredProfiles.length > 0) { + for (const profileId of filteredProfiles) { + const profile = store.profiles[profileId]; + const mode = profile?.type; + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + if (!model) { + results.push({ + provider: providerKey, + model: undefined, + profileId, + label, + source: "profile", + mode, + status: "no_model", + error: "No model available for probe", + }); + continue; + } + targets.push({ + provider: providerKey, + model, + profileId, + label, + source: "profile", + mode, + }); + } + continue; + } + + if (profileFilter.size > 0) continue; + + const envKey = resolveEnvApiKey(providerKey); + const customKey = getCustomProviderApiKey(cfg, providerKey); + if (!envKey && !customKey) continue; + + const label = envKey ? "env" : "models.json"; + const source = envKey ? "env" : "models.json"; + const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key"; + + if (!model) { + results.push({ + provider: providerKey, + model: undefined, + label, + source, + mode, + status: "no_model", + error: "No model available for probe", + }); + continue; + } + + targets.push({ + provider: providerKey, + model, + label, + source, + mode, + }); + } + + return { targets, results }; + }); +} + +async function probeTarget(params: { + cfg: ClawdbotConfig; + agentId: string; + agentDir: string; + workspaceDir: string; + sessionDir: string; + target: AuthProbeTarget; + timeoutMs: number; + maxTokens: number; +}): Promise { + const { cfg, agentId, agentDir, workspaceDir, sessionDir, target, timeoutMs, maxTokens } = params; + if (!target.model) { + return { + provider: target.provider, + model: undefined, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: "no_model", + error: "No model available for probe", + }; + } + + const sessionId = `probe-${target.provider}-${crypto.randomUUID()}`; + const sessionFile = resolveSessionTranscriptPath(sessionId, agentId); + await fs.mkdir(sessionDir, { recursive: true }); + + const start = Date.now(); + try { + await runEmbeddedPiAgent({ + sessionId, + sessionFile, + workspaceDir, + agentDir, + config: cfg, + prompt: PROBE_PROMPT, + provider: target.model.provider, + model: target.model.model, + authProfileId: target.profileId, + authProfileIdSource: target.profileId ? "user" : undefined, + timeoutMs, + runId: `probe-${crypto.randomUUID()}`, + lane: `auth-probe:${target.provider}:${target.profileId ?? target.source}`, + thinkLevel: "off", + reasoningLevel: "off", + verboseLevel: "off", + streamParams: { maxTokens }, + }); + return { + provider: target.provider, + model: `${target.model.provider}/${target.model.model}`, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: "ok", + latencyMs: Date.now() - start, + }; + } catch (err) { + const described = describeFailoverError(err); + return { + provider: target.provider, + model: `${target.model.provider}/${target.model.model}`, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: toStatus(described.reason), + error: redactSecrets(described.message), + latencyMs: Date.now() - start, + }; + } +} + +async function runTargetsWithConcurrency(params: { + cfg: ClawdbotConfig; + targets: AuthProbeTarget[]; + timeoutMs: number; + maxTokens: number; + concurrency: number; + onProgress?: (update: { completed: number; total: number; label?: string }) => void; +}): Promise { + const { cfg, targets, timeoutMs, maxTokens, onProgress } = params; + const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency)); + + const agentId = resolveDefaultAgentId(cfg); + const agentDir = resolveClawdbotAgentDir(); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const sessionDir = resolveSessionTranscriptsDirForAgent(agentId); + + await fs.mkdir(workspaceDir, { recursive: true }); + + let completed = 0; + const results: Array = Array.from({ length: targets.length }); + let cursor = 0; + + const worker = async () => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= targets.length) return; + const target = targets[index]; + onProgress?.({ + completed, + total: targets.length, + label: `Probing ${target.provider}${target.profileId ? ` (${target.label})` : ""}`, + }); + const result = await probeTarget({ + cfg, + agentId, + agentDir, + workspaceDir, + sessionDir, + target, + timeoutMs, + maxTokens, + }); + results[index] = result; + completed += 1; + onProgress?.({ completed, total: targets.length }); + } + }; + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + return results.filter((entry): entry is AuthProbeResult => Boolean(entry)); +} + +export async function runAuthProbes(params: { + cfg: ClawdbotConfig; + providers: string[]; + modelCandidates: string[]; + options: AuthProbeOptions; + onProgress?: (update: { completed: number; total: number; label?: string }) => void; +}): Promise { + const startedAt = Date.now(); + const plan = await buildProbeTargets({ + cfg: params.cfg, + providers: params.providers, + modelCandidates: params.modelCandidates, + options: params.options, + }); + + const totalTargets = plan.targets.length; + params.onProgress?.({ completed: 0, total: totalTargets }); + + const results = totalTargets + ? await runTargetsWithConcurrency({ + cfg: params.cfg, + targets: plan.targets, + timeoutMs: params.options.timeoutMs, + maxTokens: params.options.maxTokens, + concurrency: params.options.concurrency, + onProgress: params.onProgress, + }) + : []; + + const finishedAt = Date.now(); + + return { + startedAt, + finishedAt, + durationMs: finishedAt - startedAt, + totalTargets, + options: params.options, + results: [...plan.results, ...results], + }; +} + +export function formatProbeLatency(latencyMs?: number | null) { + if (!latencyMs && latencyMs !== 0) return "-"; + return formatMs(latencyMs); +} + +export function groupProbeResults(results: AuthProbeResult[]): Map { + const map = new Map(); + for (const result of results) { + const list = map.get(result.provider) ?? []; + list.push(result); + map.set(result.provider, list); + } + return map; +} + +export function sortProbeResults(results: AuthProbeResult[]): AuthProbeResult[] { + return results.slice().sort((a, b) => { + const provider = a.provider.localeCompare(b.provider); + if (provider !== 0) return provider; + const aLabel = a.label || a.profileId || ""; + const bLabel = b.label || b.profileId || ""; + return aLabel.localeCompare(bLabel); + }); +} + +export function describeProbeSummary(summary: AuthProbeSummary): string { + if (summary.totalTargets === 0) return "No probe targets."; + return `Probed ${summary.totalTargets} target${summary.totalTargets === 1 ? "" : "s"} in ${formatMs(summary.durationMs)}`; +} diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 0bd8f16e9..6b8c8c36d 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -11,9 +11,15 @@ import { resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; -import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js"; +import { + buildModelAliasIndex, + parseModelRef, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../../agents/model-selection.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; +import { withProgressTotals } from "../../cli/progress.js"; import { formatUsageWindowSummary, loadProviderUsageSummary, @@ -22,17 +28,38 @@ import { } from "../../infra/provider-usage.js"; import type { RuntimeEnv } from "../../runtime.js"; import { colorize, theme } from "../../terminal/theme.js"; +import { renderTable } from "../../terminal/table.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { shortenHomePath } from "../../utils.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; import { isRich } from "./list.format.js"; +import { + describeProbeSummary, + formatProbeLatency, + runAuthProbes, + sortProbeResults, + type AuthProbeSummary, +} from "./list.probe.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; export async function modelsStatusCommand( - opts: { json?: boolean; plain?: boolean; check?: boolean }, + opts: { + json?: boolean; + plain?: boolean; + check?: boolean; + probe?: boolean; + probeProvider?: string; + probeProfile?: string | string[]; + probeTimeout?: string; + probeConcurrency?: string; + probeMaxTokens?: string; + }, runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); + if (opts.plain && opts.probe) { + throw new Error("--probe cannot be used with --plain output."); + } const cfg = loadConfig(); const resolved = resolveConfiguredModelRef({ cfg, @@ -139,6 +166,69 @@ export async function modelsStatusCommand( .filter((provider) => !providerAuthMap.has(provider)) .sort((a, b) => a.localeCompare(b)); + const probeProfileIds = (() => { + if (!opts.probeProfile) return []; + const raw = Array.isArray(opts.probeProfile) ? opts.probeProfile : [opts.probeProfile]; + return raw + .flatMap((value) => String(value ?? "").split(",")) + .map((value) => value.trim()) + .filter(Boolean); + })(); + const probeTimeoutMs = opts.probeTimeout ? Number(opts.probeTimeout) : 8000; + if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs <= 0) { + throw new Error("--probe-timeout must be a positive number (ms)."); + } + const probeConcurrency = opts.probeConcurrency ? Number(opts.probeConcurrency) : 2; + if (!Number.isFinite(probeConcurrency) || probeConcurrency <= 0) { + throw new Error("--probe-concurrency must be > 0."); + } + const probeMaxTokens = opts.probeMaxTokens ? Number(opts.probeMaxTokens) : 8; + if (!Number.isFinite(probeMaxTokens) || probeMaxTokens <= 0) { + throw new Error("--probe-max-tokens must be > 0."); + } + + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER }); + const rawCandidates = [ + rawModel || resolvedLabel, + ...fallbacks, + imageModel, + ...imageFallbacks, + ...allowed, + ].filter(Boolean); + const resolvedCandidates = rawCandidates + .map( + (raw) => + resolveModelRefFromString({ + raw: String(raw ?? ""), + defaultProvider: DEFAULT_PROVIDER, + aliasIndex, + })?.ref, + ) + .filter((ref): ref is { provider: string; model: string } => Boolean(ref)); + const modelCandidates = resolvedCandidates.map((ref) => `${ref.provider}/${ref.model}`); + + let probeSummary: AuthProbeSummary | undefined; + if (opts.probe) { + probeSummary = await withProgressTotals( + { label: "Probing auth profiles…", total: 1 }, + async (update) => { + return await runAuthProbes({ + cfg, + providers, + modelCandidates, + options: { + provider: opts.probeProvider, + profileIds: probeProfileIds, + timeoutMs: probeTimeoutMs, + concurrency: probeConcurrency, + maxTokens: probeMaxTokens, + }, + onProgress: update, + }); + }, + ); + } + const providersWithOauth = providerAuth .filter( (entry) => @@ -228,6 +318,7 @@ export async function modelsStatusCommand( profiles: authHealth.profiles, providers: authHealth.providers, }, + probes: probeSummary, }, }, null, @@ -406,72 +497,118 @@ export async function modelsStatusCommand( runtime.log(colorize(rich, theme.heading, "OAuth/token status")); if (oauthProfiles.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); - return; - } - - const usageByProvider = new Map(); - const usageProviders = Array.from( - new Set( - oauthProfiles - .map((profile) => resolveUsageProviderId(profile.provider)) - .filter((provider): provider is UsageProviderId => Boolean(provider)), - ), - ); - if (usageProviders.length > 0) { - try { - const usageSummary = await loadProviderUsageSummary({ - providers: usageProviders, - agentDir, - timeoutMs: 3500, - }); - for (const snapshot of usageSummary.providers) { - const formatted = formatUsageWindowSummary(snapshot, { - now: Date.now(), - maxWindows: 2, - includeResets: true, + } else { + const usageByProvider = new Map(); + const usageProviders = Array.from( + new Set( + oauthProfiles + .map((profile) => resolveUsageProviderId(profile.provider)) + .filter((provider): provider is UsageProviderId => Boolean(provider)), + ), + ); + if (usageProviders.length > 0) { + try { + const usageSummary = await loadProviderUsageSummary({ + providers: usageProviders, + agentDir, + timeoutMs: 3500, }); - if (formatted) { - usageByProvider.set(snapshot.provider, formatted); + for (const snapshot of usageSummary.providers) { + const formatted = formatUsageWindowSummary(snapshot, { + now: Date.now(), + maxWindows: 2, + includeResets: true, + }); + if (formatted) { + usageByProvider.set(snapshot.provider, formatted); + } } + } catch { + // ignore usage failures + } + } + + const formatStatus = (status: string) => { + if (status === "ok") return colorize(rich, theme.success, "ok"); + if (status === "static") return colorize(rich, theme.muted, "static"); + if (status === "expiring") return colorize(rich, theme.warn, "expiring"); + if (status === "missing") return colorize(rich, theme.warn, "unknown"); + return colorize(rich, theme.error, "expired"); + }; + + const profilesByProvider = new Map(); + for (const profile of oauthProfiles) { + const current = profilesByProvider.get(profile.provider); + if (current) current.push(profile); + else profilesByProvider.set(profile.provider, [profile]); + } + + for (const [provider, profiles] of profilesByProvider) { + const usageKey = resolveUsageProviderId(provider); + const usage = usageKey ? usageByProvider.get(usageKey) : undefined; + const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; + runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); + for (const profile of profiles) { + const labelText = profile.label || profile.profileId; + const label = colorize(rich, theme.accent, labelText); + const status = formatStatus(profile.status); + const expiry = + profile.status === "static" + ? "" + : profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; + const source = + profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; + runtime.log(` - ${label} ${status}${expiry}${source}`); } - } catch { - // ignore usage failures } } - const formatStatus = (status: string) => { - if (status === "ok") return colorize(rich, theme.success, "ok"); - if (status === "static") return colorize(rich, theme.muted, "static"); - if (status === "expiring") return colorize(rich, theme.warn, "expiring"); - if (status === "missing") return colorize(rich, theme.warn, "unknown"); - return colorize(rich, theme.error, "expired"); - }; - - const profilesByProvider = new Map(); - for (const profile of oauthProfiles) { - const current = profilesByProvider.get(profile.provider); - if (current) current.push(profile); - else profilesByProvider.set(profile.provider, [profile]); - } - - for (const [provider, profiles] of profilesByProvider) { - const usageKey = resolveUsageProviderId(provider); - const usage = usageKey ? usageByProvider.get(usageKey) : undefined; - const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; - runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); - for (const profile of profiles) { - const labelText = profile.label || profile.profileId; - const label = colorize(rich, theme.accent, labelText); - const status = formatStatus(profile.status); - const expiry = - profile.status === "static" - ? "" - : profile.expiresAt - ? ` expires in ${formatRemainingShort(profile.remainingMs)}` - : " expires unknown"; - const source = - profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; - runtime.log(` - ${label} ${status}${expiry}${source}`); + if (probeSummary) { + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Auth probes")); + if (probeSummary.results.length === 0) { + runtime.log(colorize(rich, theme.muted, "- none")); + } else { + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const sorted = sortProbeResults(probeSummary.results); + const statusColor = (status: string) => { + if (status === "ok") return theme.success; + if (status === "rate_limit") return theme.warn; + if (status === "timeout" || status === "billing") return theme.warn; + if (status === "auth" || status === "format") return theme.error; + if (status === "no_model") return theme.muted; + return theme.muted; + }; + const rows = sorted.map((result) => { + const status = colorize(rich, statusColor(result.status), result.status); + const latency = formatProbeLatency(result.latencyMs); + const detail = result.error ? colorize(rich, theme.muted, result.error) : ""; + const modelLabel = result.model ?? `${result.provider}/-`; + const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : ""; + const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`; + const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}`; + return { + Model: colorize(rich, theme.heading, modelLabel), + Profile: profile, + Status: statusLabel, + Detail: detail, + }; + }); + runtime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Model", header: "Model", minWidth: 18 }, + { key: "Profile", header: "Profile", minWidth: 24 }, + { key: "Status", header: "Status", minWidth: 12 }, + { key: "Detail", header: "Detail", minWidth: 16, flex: true }, + ], + rows, + }).trimEnd(), + ); + runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary))); } } diff --git a/src/discord/monitor.slash.test.ts b/src/discord/monitor.slash.test.ts index 018f93ed0..af098eb96 100644 --- a/src/discord/monitor.slash.test.ts +++ b/src/discord/monitor.slash.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; const dispatchMock = vi.fn(); @@ -20,15 +21,34 @@ vi.mock("@buape/carbon", () => ({ }, })); -vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), -})); +vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args), + }; +}); beforeEach(() => { - dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { - dispatcher.sendToolResult({ text: "tool update" }); - dispatcher.sendFinalReply({ text: "final reply" }); - return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } }; + dispatchMock.mockReset().mockImplementation(async (params) => { + if ("dispatcher" in params && params.dispatcher) { + params.dispatcher.sendToolResult({ text: "tool update" }); + params.dispatcher.sendFinalReply({ text: "final reply" }); + return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } }; + } + if ("dispatcherOptions" in params && params.dispatcherOptions) { + const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping( + params.dispatcherOptions, + ); + dispatcher.sendToolResult({ text: "tool update" }); + dispatcher.sendFinalReply({ text: "final reply" }); + await dispatcher.waitForIdle(); + markDispatchIdle(); + return { queuedFinal: true, counts: dispatcher.getQueuedCounts() }; + } + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; }); }); diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index be0c8aa65..bc85e5764 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -377,12 +377,63 @@ describe("discord mention gating", () => { resolveDiscordShouldRequireMention({ isGuildMessage: true, isThread: true, + botId: "bot123", + threadOwnerId: "bot123", channelConfig, guildInfo, }), ).toBe(false); }); + it("requires mention inside user-created threads with autoThread enabled", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + threadOwnerId: "user456", + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + + it("requires mention when thread owner is unknown", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + it("inherits parent channel mention rules for threads", () => { const guildInfo: DiscordGuildEntryResolved = { requireMention: true, diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index b31387b45..d91c7b3d3 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({ reactMock(...args); }, })); -vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), -})); +vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args), + }; +}); vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), @@ -41,7 +47,7 @@ beforeEach(() => { updateLastRouteMock.mockReset(); dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatcher.sendFinalReply({ text: "hi" }); - return { queuedFinal: true, counts: { final: 1 } }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; }); readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts index 9da41c577..88fd6e212 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({ reactMock(...args); }, })); -vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), -})); +vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args), + }; +}); vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), @@ -40,7 +46,7 @@ beforeEach(() => { updateLastRouteMock.mockReset(); dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatcher.sendFinalReply({ text: "hi" }); - return { queuedFinal: true, counts: { final: 1 } }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; }); readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 7d495af66..12c2d1d39 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -282,14 +282,33 @@ export function resolveDiscordChannelConfigWithFallback(params: { export function resolveDiscordShouldRequireMention(params: { isGuildMessage: boolean; isThread: boolean; + botId?: string | null; + threadOwnerId?: string | null; channelConfig?: DiscordChannelConfigResolved | null; guildInfo?: DiscordGuildEntryResolved | null; + /** Pass pre-computed value to avoid redundant checks. */ + isAutoThreadOwnedByBot?: boolean; }): boolean { if (!params.isGuildMessage) return false; - if (params.isThread && params.channelConfig?.autoThread) return false; + // Only skip mention requirement in threads created by the bot (when autoThread is enabled). + const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params); + if (isBotThread) return false; return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true; } +export function isDiscordAutoThreadOwnedByBot(params: { + isThread: boolean; + channelConfig?: DiscordChannelConfigResolved | null; + botId?: string | null; + threadOwnerId?: string | null; +}): boolean { + if (!params.isThread) return false; + if (!params.channelConfig?.autoThread) return false; + const botId = params.botId?.trim(); + const threadOwnerId = params.threadOwnerId?.trim(); + return Boolean(botId && threadOwnerId && botId === threadOwnerId); +} + export function isDiscordGroupAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; guildAllowlisted: boolean; diff --git a/src/discord/monitor/message-handler.inbound-contract.test.ts b/src/discord/monitor/message-handler.inbound-contract.test.ts index 1ffecb293..708c69993 100644 --- a/src/discord/monitor/message-handler.inbound-contract.test.ts +++ b/src/discord/monitor/message-handler.inbound-contract.test.ts @@ -9,17 +9,24 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont let capturedCtx: MsgContext | undefined; -vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => { +vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => { capturedCtx = params.ctx; - return { queuedFinal: false, counts: { tool: 0, block: 0 } }; - }), -})); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }); + return { + ...actual, + dispatchInboundMessage, + dispatchInboundMessageWithDispatcher: dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage, + }; +}); import { processDiscordMessage } from "./message-handler.process.js"; describe("discord processDiscordMessage inbound contract", () => { - it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => { + it("passes a finalized MsgContext to dispatchInboundMessage", async () => { capturedCtx = undefined; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-")); diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 6df141e35..5245fe253 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -2,7 +2,10 @@ import { ChannelType, MessageType, type User } from "@buape/carbon"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; -import { recordPendingHistoryEntry, type HistoryEntry } from "../../auto-reply/reply/history.js"; +import { + recordPendingHistoryEntryIfEnabled, + type HistoryEntry, +} from "../../auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { recordChannelActivity } from "../../infra/channel-activity.js"; @@ -18,6 +21,7 @@ import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { sendMessageDiscord } from "../send.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js"; +import { logInboundDrop } from "../../channels/logging.js"; import { allowListMatches, isDiscordGroupAllowedByPolicy, @@ -328,9 +332,12 @@ export async function preflightDiscordMessage( } satisfies HistoryEntry) : undefined; + const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined; const shouldRequireMention = resolveDiscordShouldRequireMention({ isGuildMessage, isThread: Boolean(threadChannel), + botId, + threadOwnerId, channelConfig, guildInfo, }); @@ -379,7 +386,12 @@ export async function preflightDiscordMessage( commandAuthorized = commandGate.commandAuthorized; if (commandGate.shouldBlock) { - logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`); + logInboundDrop({ + log: logVerbose, + channel: "discord", + reason: "control command (unauthorized)", + target: author.id, + }); return null; } } @@ -407,14 +419,12 @@ export async function preflightDiscordMessage( }, "discord: skipping guild message", ); - if (historyEntry && params.historyLimit > 0) { - recordPendingHistoryEntry({ - historyMap: params.guildHistories, - historyKey: message.channelId, - limit: params.historyLimit, - entry: historyEntry, - }); - } + recordPendingHistoryEntryIfEnabled({ + historyMap: params.guildHistories, + historyKey: message.channelId, + limit: params.historyLimit, + entry: historyEntry ?? null, + }); return null; } } diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts new file mode 100644 index 000000000..351f46f74 --- /dev/null +++ b/src/discord/monitor/message-handler.process.test.ts @@ -0,0 +1,123 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const reactMessageDiscord = vi.fn(async () => {}); +const removeReactionDiscord = vi.fn(async () => {}); + +vi.mock("../send.js", () => ({ + reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args), + removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args), +})); + +vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: false, + counts: { final: 0, tool: 0, block: 0 }, + })), +})); + +vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ + createReplyDispatcherWithTyping: vi.fn(() => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + })), +})); + +import { processDiscordMessage } from "./message-handler.process.js"; + +async function createBaseContext(overrides: Record = {}) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-")); + const storePath = path.join(dir, "sessions.json"); + return { + cfg: { messages: { ackReaction: "👀" }, session: { store: storePath } }, + discordConfig: {}, + accountId: "default", + token: "token", + runtime: { log: () => {}, error: () => {} }, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1024, + textLimit: 4000, + replyToMode: "off", + ackReactionScope: "group-mentions", + groupPolicy: "open", + data: { guild: { id: "g1", name: "Guild" } }, + client: { rest: {} }, + message: { + id: "m1", + channelId: "c1", + timestamp: new Date().toISOString(), + attachments: [], + }, + author: { + id: "U1", + username: "alice", + discriminator: "0", + globalName: "Alice", + }, + channelInfo: { name: "general" }, + channelName: "general", + isGuildMessage: true, + isDirectMessage: false, + isGroupDm: false, + commandAuthorized: true, + baseText: "hi", + messageText: "hi", + wasMentioned: false, + shouldRequireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + shouldBypassMention: false, + threadChannel: null, + threadParentId: undefined, + threadParentName: undefined, + threadParentType: undefined, + threadName: undefined, + displayChannelSlug: "general", + guildInfo: null, + guildSlug: "guild", + channelConfig: null, + baseSessionKey: "agent:main:discord:guild:g1", + route: { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:discord:guild:g1", + mainSessionKey: "agent:main:main", + }, + ...overrides, + }; +} + +beforeEach(() => { + reactMessageDiscord.mockClear(); + removeReactionDiscord.mockClear(); +}); + +describe("processDiscordMessage ack reactions", () => { + it("skips ack reactions for group-mentions when mentions are not required", async () => { + const ctx = await createBaseContext({ + shouldRequireMention: false, + effectiveWasMentioned: false, + }); + + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).not.toHaveBeenCalled(); + }); + + it("sends ack reactions for mention-gated guild messages when mentioned", async () => { + const ctx = await createBaseContext({ + shouldRequireMention: true, + effectiveWasMentioned: true, + }); + + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} }); + }); +}); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index ad1e4baea..f575e6e55 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -1,32 +1,26 @@ +import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js"; import { - resolveAckReaction, - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../auto-reply/reply/response-prefix-template.js"; + removeAckReactionAfterReply, + shouldAckReaction as shouldAckReactionGate, +} from "../../channels/ack-reactions.js"; +import { logTypingFailure, logAckFailure } from "../../channels/logging.js"; +import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; +import { createTypingCallbacks } from "../../channels/typing.js"; import { formatInboundEnvelope, formatThreadStarterEnvelope, resolveEnvelopeFormatOptions, } from "../../auto-reply/envelope.js"; -import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, } from "../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../../config/sessions.js"; +import { recordInboundSession } from "../../channels/session.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; @@ -73,6 +67,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) shouldRequireMention, canDetectMention, effectiveWasMentioned, + shouldBypassMention, threadChannel, threadParentId, threadParentName, @@ -95,20 +90,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) } const ackReaction = resolveAckReaction(cfg, route.agentId); const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return isDirectMessage; - const isGroupChat = isGuildMessage || isGroupDm; - if (ackReactionScope === "group-all") return isGroupChat; - if (ackReactionScope === "group-mentions") { - if (!isGuildMessage) return false; - if (!shouldRequireMention) return false; - if (!canDetectMention) return false; - return effectiveWasMentioned; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReaction && + shouldAckReactionGate({ + scope: ackReactionScope, + isDirect: isDirectMessage, + isGroup: isGuildMessage || isGroupDm, + isMentionableGroup: isGuildMessage, + requireMention: Boolean(shouldRequireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention, + }), + ); const ackReactionPromise = shouldAckReaction() ? reactMessageDiscord(message.channelId, message.id, ackReaction, { rest: client.rest, @@ -288,27 +283,23 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, }); - void recordSessionMetaFromInbound({ + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`discord: failed updating session meta: ${String(err)}`); + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "discord", + to: `user:${author.id}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logVerbose(`discord: failed updating session meta: ${String(err)}`); + }, }); - if (isDirectMessage) { - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "discord", - to: `user:${author.id}`, - accountId: route.accountId, - }, - ctx: ctxPayload, - }); - } - if (shouldLogVerbose()) { const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n"); logVerbose( @@ -320,10 +311,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ? deliverTarget.slice("channel:".length) : message.channelId; - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "discord", @@ -331,8 +319,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const replyToId = replyReference.use(); @@ -353,10 +341,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) onError: (err, info) => { runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: () => sendTyping({ client, channelId: typingChannelId }), + onReplyStart: createTypingCallbacks({ + start: () => sendTyping({ client, channelId: typingChannelId }), + onStartError: (err) => { + logTypingFailure({ + log: logVerbose, + channel: "discord", + target: typingChannelId, + error: err, + }); + }, + }).onReplyStart, }); - const { queuedFinal, counts } = await dispatchReplyFromConfig({ + const { queuedFinal, counts } = await dispatchInboundMessage({ ctx: ctxPayload, cfg, dispatcher, @@ -368,20 +366,17 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ? !discordConfig.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + prefixContext.onModelSelected(ctx); }, }, }); markDispatchIdle(); if (!queuedFinal) { - if (isGuildMessage && historyLimit > 0) { - clearHistoryEntries({ + if (isGuildMessage) { + clearHistoryEntriesIfEnabled({ historyMap: guildHistories, historyKey: message.channelId, + limit: historyLimit, }); } return; @@ -392,23 +387,29 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) `discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); } - if (removeAckAfterReply && ackReactionPromise && ackReaction) { - const ackReactionValue = ackReaction; - void ackReactionPromise.then((didAck) => { - if (!didAck) return; - removeReactionDiscord(message.channelId, message.id, ackReactionValue, { + removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReaction, + remove: async () => { + await removeReactionDiscord(message.channelId, message.id, ackReaction, { rest: client.rest, - }).catch((err) => { - logVerbose( - `discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`, - ); }); - }); - } - if (isGuildMessage && historyLimit > 0) { - clearHistoryEntries({ + }, + onError: (err) => { + logAckFailure({ + log: logVerbose, + channel: "discord", + target: `${message.channelId}/${message.id}`, + error: err, + }); + }, + }); + if (isGuildMessage) { + clearHistoryEntriesIfEnabled({ historyMap: guildHistories, historyKey: message.channelId, + limit: historyLimit, }); } } diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index a681afa16..2647e5113 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -16,6 +16,7 @@ export type DiscordChannelInfo = { name?: string; topic?: string; parentId?: string; + ownerId?: string; }; type DiscordSnapshotAuthor = { @@ -69,11 +70,13 @@ export async function resolveDiscordChannelInfo( const name = "name" in channel ? (channel.name ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; + const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined; const payload: DiscordChannelInfo = { type: channel.type, name, topic, parentId, + ownerId, }; DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value: payload, diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index bae4ef1c5..71af6408f 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -14,6 +14,7 @@ export type DiscordThreadChannel = { name?: string | null; parentId?: string | null; parent?: { id?: string; name?: string }; + ownerId?: string | null; }; export type DiscordThreadStarter = { @@ -63,6 +64,7 @@ export function resolveDiscordThreadChannel(params: { name: channelInfo?.name ?? undefined, parentId: channelInfo?.parentId ?? undefined, parent: undefined, + ownerId: channelInfo?.ownerId ?? undefined, }; } diff --git a/src/discord/monitor/typing.ts b/src/discord/monitor/typing.ts index f1ae61fdc..e9ce734d4 100644 --- a/src/discord/monitor/typing.ts +++ b/src/discord/monitor/typing.ts @@ -1,15 +1,9 @@ import type { Client } from "@buape/carbon"; -import { logVerbose } from "../../globals.js"; - export async function sendTyping(params: { client: Client; channelId: string }) { - try { - const channel = await params.client.fetchChannel(params.channelId); - if (!channel) return; - if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") { - await channel.triggerTyping(); - } - } catch (err) { - logVerbose(`discord typing cue failed for channel ${params.channelId}: ${String(err)}`); + const channel = await params.client.fetchChannel(params.channelId); + if (!channel) return; + if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") { + await channel.triggerTyping(); } } diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index d58e74ab2..e6f7ce906 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -92,13 +92,13 @@ export const ExecApprovalRequestParamsSchema = Type.Object( { id: Type.Optional(NonEmptyString), command: NonEmptyString, - cwd: Type.Optional(Type.String()), - host: Type.Optional(Type.String()), - security: Type.Optional(Type.String()), - ask: Type.Optional(Type.String()), - agentId: Type.Optional(Type.String()), - resolvedPath: Type.Optional(Type.String()), - sessionKey: Type.Optional(Type.String()), + cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])), + host: Type.Optional(Type.Union([Type.String(), Type.Null()])), + security: Type.Optional(Type.Union([Type.String(), Type.Null()])), + ask: Type.Optional(Type.Union([Type.String(), Type.Null()])), + agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])), + sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])), timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 8c71dca75..50f441779 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -2,14 +2,18 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; -import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; -import { agentCommand } from "../../commands/agent.js"; -import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js"; -import { registerAgentRunContext } from "../../infra/agent-events.js"; -import { isAcpSessionKey } from "../../routing/session-key.js"; -import { defaultRuntime } from "../../runtime.js"; +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; +import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; +import { + extractShortModelName, + type ResponsePrefixContext, +} from "../../auto-reply/reply/response-prefix-template.js"; +import type { MsgContext } from "../../auto-reply/templating.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { @@ -37,7 +41,144 @@ import { } from "../session-utils.js"; import { stripEnvelopeFromMessages } from "../chat-sanitize.js"; import { formatForLog } from "../ws-log.js"; -import type { GatewayRequestHandlers } from "./types.js"; +import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; + +type TranscriptAppendResult = { + ok: boolean; + messageId?: string; + message?: Record; + error?: string; +}; + +function resolveTranscriptPath(params: { + sessionId: string; + storePath: string | undefined; + sessionFile?: string; +}): string | null { + const { sessionId, storePath, sessionFile } = params; + if (sessionFile) return sessionFile; + if (!storePath) return null; + return path.join(path.dirname(storePath), `${sessionId}.jsonl`); +} + +function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): { + ok: boolean; + error?: string; +} { + if (fs.existsSync(params.transcriptPath)) return { ok: true }; + try { + fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true }); + const header = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: params.sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }; + fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8"); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +function appendAssistantTranscriptMessage(params: { + message: string; + label?: string; + sessionId: string; + storePath: string | undefined; + sessionFile?: string; + createIfMissing?: boolean; +}): TranscriptAppendResult { + const transcriptPath = resolveTranscriptPath({ + sessionId: params.sessionId, + storePath: params.storePath, + sessionFile: params.sessionFile, + }); + if (!transcriptPath) { + return { ok: false, error: "transcript path not resolved" }; + } + + if (!fs.existsSync(transcriptPath)) { + if (!params.createIfMissing) { + return { ok: false, error: "transcript file not found" }; + } + const ensured = ensureTranscriptFile({ + transcriptPath, + sessionId: params.sessionId, + }); + if (!ensured.ok) { + return { ok: false, error: ensured.error ?? "failed to create transcript file" }; + } + } + + const now = Date.now(); + const messageId = randomUUID().slice(0, 8); + const labelPrefix = params.label ? `[${params.label}]\n\n` : ""; + const messageBody: Record = { + role: "assistant", + content: [{ type: "text", text: `${labelPrefix}${params.message}` }], + timestamp: now, + stopReason: "injected", + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + const transcriptEntry = { + type: "message", + id: messageId, + timestamp: new Date(now).toISOString(), + message: messageBody, + }; + + try { + fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8"); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + + return { ok: true, messageId, message: transcriptEntry.message }; +} + +function nextChatSeq(context: { agentRunSeq: Map }, runId: string) { + const next = (context.agentRunSeq.get(runId) ?? 0) + 1; + context.agentRunSeq.set(runId, next); + return next; +} + +function broadcastChatFinal(params: { + context: Pick; + runId: string; + sessionKey: string; + message?: Record; +}) { + const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); + const payload = { + runId: params.runId, + sessionKey: params.sessionKey, + seq, + state: "final" as const, + message: params.message, + }; + params.context.broadcast("chat", payload); + params.context.nodeSendToSession(params.sessionKey, "chat", payload); +} + +function broadcastChatError(params: { + context: Pick; + runId: string; + sessionKey: string; + errorMessage?: string; +}) { + const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); + const payload = { + runId: params.runId, + sessionKey: params.sessionKey, + seq, + state: "error" as const, + errorMessage: params.errorMessage, + }; + params.context.broadcast("chat", payload); + params.context.nodeSendToSession(params.sessionKey, "chat", payload); +} export const chatHandlers: GatewayRequestHandlers = { "chat.history": async ({ params, respond, context }) => { @@ -152,7 +293,7 @@ export const chatHandlers: GatewayRequestHandlers = { runIds: res.aborted ? [runId] : [], }); }, - "chat.send": async ({ params, respond, context }) => { + "chat.send": async ({ params, respond, context, client }) => { if (!validateChatSendParams(params)) { respond( false, @@ -212,19 +353,13 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } - const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey); + const { cfg, entry } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, }); const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - const sessionEntry = mergeSessionEntry(entry, { - sessionId, - updatedAt: now, - }); const clientRunId = p.idempotencyKey; - registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); const sendPolicy = resolveSendPolicy({ cfg, @@ -281,21 +416,11 @@ export const chatHandlers: GatewayRequestHandlers = { const abortController = new AbortController(); context.chatAbortControllers.set(clientRunId, { controller: abortController, - sessionId, + sessionId: entry?.sessionId ?? clientRunId, sessionKey: p.sessionKey, startedAtMs: now, expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }), }); - context.addChatRun(clientRunId, { - sessionKey: p.sessionKey, - clientRunId, - }); - - if (storePath) { - await updateSessionStore(storePath, (store) => { - store[canonicalKey] = sessionEntry; - }); - } const ackPayload = { runId: clientRunId, @@ -303,35 +428,116 @@ export const chatHandlers: GatewayRequestHandlers = { }; respond(true, ackPayload, undefined, { runId: clientRunId }); - const envelopeOptions = resolveEnvelopeFormatOptions(cfg); - const envelopedMessage = formatInboundEnvelope({ - channel: "WebChat", - from: p.sessionKey, - timestamp: now, - body: parsedMessage, - chatType: "direct", - previousTimestamp: entry?.updatedAt, - envelope: envelopeOptions, + const trimmedMessage = parsedMessage.trim(); + const injectThinking = Boolean( + p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"), + ); + const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage; + const clientInfo = client?.connect?.client; + const ctx: MsgContext = { + Body: parsedMessage, + BodyForAgent: parsedMessage, + BodyForCommands: commandBody, + RawBody: parsedMessage, + CommandBody: commandBody, + SessionKey: p.sessionKey, + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + OriginatingChannel: INTERNAL_MESSAGE_CHANNEL, + ChatType: "direct", + CommandAuthorized: true, + MessageSid: clientRunId, + SenderId: clientInfo?.id, + SenderName: clientInfo?.displayName, + SenderUsername: clientInfo?.displayName, + }; + + const agentId = resolveSessionAgentId({ + sessionKey: p.sessionKey, + config: cfg, }); - const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined; - void agentCommand( - { - message: envelopedMessage, - images: parsedImages.length > 0 ? parsedImages : undefined, - sessionId, - sessionKey: p.sessionKey, - runId: clientRunId, - thinking: p.thinking, - deliver: p.deliver, - timeout: Math.ceil(timeoutMs / 1000).toString(), - messageChannel: INTERNAL_MESSAGE_CHANNEL, - abortSignal: abortController.signal, - lane, + let prefixContext: ResponsePrefixContext = { + identityName: resolveIdentityName(cfg, agentId), + }; + const finalReplyParts: string[] = []; + const dispatcher = createReplyDispatcher({ + responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix, + responsePrefixContextProvider: () => prefixContext, + onError: (err) => { + context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`); }, - defaultRuntime, - context.deps, - ) + deliver: async (payload, info) => { + if (info.kind !== "final") return; + const text = payload.text?.trim() ?? ""; + if (!text) return; + finalReplyParts.push(text); + }, + }); + + let agentRunStarted = false; + void dispatchInboundMessage({ + ctx, + cfg, + dispatcher, + replyOptions: { + runId: clientRunId, + abortSignal: abortController.signal, + images: parsedImages.length > 0 ? parsedImages : undefined, + disableBlockStreaming: true, + onAgentRunStart: () => { + agentRunStarted = true; + }, + onModelSelected: (ctx) => { + prefixContext.provider = ctx.provider; + prefixContext.model = extractShortModelName(ctx.model); + prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; + prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + }, + }, + }) .then(() => { + if (!agentRunStarted) { + const combinedReply = finalReplyParts + .map((part) => part.trim()) + .filter(Boolean) + .join("\n\n") + .trim(); + let message: Record | undefined; + if (combinedReply) { + const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( + p.sessionKey, + ); + const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId; + const appended = appendAssistantTranscriptMessage({ + message: combinedReply, + sessionId, + storePath: latestStorePath, + sessionFile: latestEntry?.sessionFile, + createIfMissing: true, + }); + if (appended.ok) { + message = appended.message; + } else { + context.logGateway.warn( + `webchat transcript append failed: ${appended.error ?? "unknown error"}`, + ); + const now = Date.now(); + message = { + role: "assistant", + content: [{ type: "text", text: combinedReply }], + timestamp: now, + stopReason: "injected", + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + } + } + broadcastChatFinal({ + context, + runId: clientRunId, + sessionKey: p.sessionKey, + message, + }); + } context.dedupe.set(`chat:${clientRunId}`, { ts: Date.now(), ok: true, @@ -350,6 +556,12 @@ export const chatHandlers: GatewayRequestHandlers = { }, error, }); + broadcastChatError({ + context, + runId: clientRunId, + sessionKey: p.sessionKey, + errorMessage: String(err), + }); }) .finally(() => { context.chatAbortControllers.delete(clientRunId); diff --git a/src/gateway/server-methods/exec-approval.test.ts b/src/gateway/server-methods/exec-approval.test.ts index 0b1da93f3..71a63e5a3 100644 --- a/src/gateway/server-methods/exec-approval.test.ts +++ b/src/gateway/server-methods/exec-approval.test.ts @@ -36,16 +36,16 @@ describe("exec approval handlers", () => { expect(validateExecApprovalRequestParams(params)).toBe(true); }); - // This documents the TypeBox/AJV behavior that caused the Discord exec bug: - // Type.Optional(Type.String()) does NOT accept null, only string or undefined. - it("rejects request with resolvedPath as null", () => { + // Fixed: null is now accepted (Type.Union([Type.String(), Type.Null()])) + // This matches the calling code in bash-tools.exec.ts which passes null. + it("accepts request with resolvedPath as null", () => { const params = { command: "echo hi", cwd: "/tmp", host: "node", resolvedPath: null, }; - expect(validateExecApprovalRequestParams(params)).toBe(false); + expect(validateExecApprovalRequestParams(params)).toBe(true); }); }); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index f31c726bb..df59a3f31 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -251,6 +251,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, + // Reset token counts to 0 on session reset (#1523) + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, }; store[primaryKey] = nextEntry; return nextEntry; diff --git a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts index 2b55b0c2e..e5c6c37aa 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts @@ -4,8 +4,8 @@ import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; import { - agentCommand, connectOk, + getReplyFromConfig, installGatewayTestHooks, onceMessage, rpcReq, @@ -47,7 +47,7 @@ describe("gateway server chat", () => { async () => { const tempDirs: string[] = []; const { server, ws } = await startServerWithClient(); - const spy = vi.mocked(agentCommand); + const spy = vi.mocked(getReplyFromConfig); const resetSpy = () => { spy.mockReset(); spy.mockResolvedValue(undefined); @@ -122,8 +122,9 @@ describe("gateway server chat", () => { let abortInFlight: Promise | undefined; try { const callsBefore = spy.mock.calls.length; - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-1"); + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); @@ -155,7 +156,7 @@ describe("gateway server chat", () => { const tick = () => { if (spy.mock.calls.length > callsBefore) return resolve(); if (Date.now() > deadline) - return reject(new Error("timeout waiting for agentCommand")); + return reject(new Error("timeout waiting for getReplyFromConfig")); setTimeout(tick, 5); }; tick(); @@ -177,8 +178,9 @@ describe("gateway server chat", () => { sessionStoreSaveDelayMs.value = 120; resetSpy(); try { - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-save-1"); + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); @@ -215,8 +217,9 @@ describe("gateway server chat", () => { await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } }); resetSpy(); const callsBeforeStop = spy.mock.calls.length; - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-stop-1"); + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); @@ -261,7 +264,8 @@ describe("gateway server chat", () => { const runDone = new Promise((resolve) => { resolveRun = resolve; }); - spy.mockImplementationOnce(async () => { + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-status-1"); await runDone; }); const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { @@ -294,8 +298,9 @@ describe("gateway server chat", () => { } expect(completed).toBe(true); resetSpy(); - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-all-1"); + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); @@ -359,9 +364,9 @@ describe("gateway server chat", () => { const agentStartedP = new Promise((resolve) => { agentStartedResolve = resolve; }); - spy.mockImplementationOnce(async (opts) => { + spy.mockImplementationOnce(async (_ctx, opts) => { agentStartedResolve?.(); - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts index 75f541f39..54f772580 100644 --- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -6,8 +6,8 @@ import { WebSocket } from "ws"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; import { - agentCommand, connectOk, + getReplyFromConfig, installGatewayTestHooks, onceMessage, rpcReq, @@ -71,7 +71,7 @@ describe("gateway server chat", () => { webchatWs.close(); webchatWs = undefined; - const spy = vi.mocked(agentCommand); + const spy = vi.mocked(getReplyFromConfig); spy.mockClear(); testState.agentConfig = { timeoutSeconds: 123 }; const callsBeforeTimeout = spy.mock.calls.length; @@ -83,8 +83,8 @@ describe("gateway server chat", () => { expect(timeoutRes.ok).toBe(true); await waitFor(() => spy.mock.calls.length > callsBeforeTimeout); - const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined; - expect(timeoutCall?.timeout).toBe("123"); + const timeoutCall = spy.mock.calls.at(-1)?.[1] as { runId?: string } | undefined; + expect(timeoutCall?.runId).toBe("idem-timeout-1"); testState.agentConfig = undefined; spy.mockClear(); @@ -97,8 +97,8 @@ describe("gateway server chat", () => { expect(sessionRes.ok).toBe(true); await waitFor(() => spy.mock.calls.length > callsBeforeSession); - const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined; - expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc"); + const sessionCall = spy.mock.calls.at(-1)?.[0] as { SessionKey?: string } | undefined; + expect(sessionCall?.SessionKey).toBe("agent:main:subagent:abc"); const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); tempDirs.push(sendPolicyDir); @@ -203,10 +203,10 @@ describe("gateway server chat", () => { expect(imgRes.payload?.runId).toBeDefined(); await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000); - const imgCall = spy.mock.calls.at(-1)?.[0] as + const imgOpts = spy.mock.calls.at(-1)?.[1] as | { images?: Array<{ type: string; data: string; mimeType: string }> } | undefined; - expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); + expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); tempDirs.push(historyDir); @@ -259,6 +259,45 @@ describe("gateway server chat", () => { } }); + test("routes chat.send slash commands without agent runs", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + try { + testState.sessionStorePath = path.join(dir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const spy = vi.mocked(agentCommand); + const callsBefore = spy.mock.calls.length; + const eventPromise = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "chat" && + o.payload?.state === "final" && + o.payload?.runId === "idem-command-1", + 8000, + ); + const res = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "/context list", + idempotencyKey: "idem-command-1", + }); + expect(res.ok).toBe(true); + const evt = await eventPromise; + expect(evt.payload?.message?.command).toBe(true); + expect(spy.mock.calls.length).toBe(callsBefore); + } finally { + testState.sessionStorePath = undefined; + await fs.rm(dir, { recursive: true, force: true }); + } + }); + test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 46631ba09..5268bd459 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -166,6 +166,7 @@ const hoisted = vi.hoisted(() => ({ waitCalls: [] as string[], waitResults: new Map(), }, + getReplyFromConfig: vi.fn().mockResolvedValue(undefined), sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }), })); @@ -197,6 +198,7 @@ export const testTailnetIPv4 = hoisted.testTailnetIPv4; export const piSdkMock = hoisted.piSdkMock; export const cronIsolatedRun = hoisted.cronIsolatedRun; export const agentCommand = hoisted.agentCommand; +export const getReplyFromConfig = hoisted.getReplyFromConfig; export const testState = { agentConfig: undefined as Record | undefined, @@ -540,6 +542,9 @@ vi.mock("../channels/web/index.js", async () => { vi.mock("../commands/agent.js", () => ({ agentCommand, })); +vi.mock("../auto-reply/reply.js", () => ({ + getReplyFromConfig, +})); vi.mock("../cli/deps.js", async () => { const actual = await vi.importActual("../cli/deps.js"); const base = actual.createDefaultDeps(); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 65185087b..fa0ce2195 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -1,14 +1,6 @@ import fs from "node:fs/promises"; -import { - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../auto-reply/reply/response-prefix-template.js"; +import { resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { @@ -20,28 +12,26 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; -import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, type HistoryEntry, } from "../../auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; +import { logInboundDrop } from "../../channels/logging.js"; +import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; +import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../../config/sessions.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { waitForTransportReady } from "../../infra/transport-ready.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -52,7 +42,7 @@ import { } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { truncateUtf16Safe } from "../../utils.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { resolveIMessageAccount } from "../accounts.js"; import { createIMessageRpcClient } from "../client.js"; import { probeIMessage } from "../probe.js"; @@ -383,41 +373,44 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P chatIdentifier, }) : false; - const commandAuthorized = isGroup - ? resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, - { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, - ], - }) - : dmAuthorized; - if (isGroup && hasControlCommand(messageText, cfg) && !commandAuthorized) { - logVerbose(`imessage: drop control command from unauthorized sender ${sender}`); + const hasControlCommandInMessage = hasControlCommand(messageText, cfg); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, + ], + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + }); + const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized; + if (isGroup && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerbose, + channel: "imessage", + reason: "control command (unauthorized)", + target: sender, + }); return; } const shouldBypassMention = - isGroup && - requireMention && - !mentioned && - commandAuthorized && - hasControlCommand(messageText); + isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage; const effectiveWasMentioned = mentioned || shouldBypassMention; if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) { logVerbose(`imessage: skipping group message (no mention)`); - if (historyKey && historyLimit > 0) { - recordPendingHistoryEntry({ - historyMap: groupHistories, - historyKey, - limit: historyLimit, - entry: { - sender: senderNormalized, - body: bodyText, - timestamp: createdAt, - messageId: message.id ? String(message.id) : undefined, - }, - }); - } + recordPendingHistoryEntryIfEnabled({ + historyMap: groupHistories, + historyKey: historyKey ?? "", + limit: historyLimit, + entry: historyKey + ? { + sender: senderNormalized, + body: bodyText, + timestamp: createdAt, + messageId: message.id ? String(message.id) : undefined, + } + : null, + }); return; } @@ -454,7 +447,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P envelope: envelopeOptions, }); let combinedBody = body; - if (isGroup && historyKey && historyLimit > 0) { + if (isGroup && historyKey) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: groupHistories, historyKey, @@ -509,30 +502,25 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P OriginatingTo: imessageTo, }); - void recordSessionMetaFromInbound({ + const updateTarget = (isGroup ? chatTarget : undefined) || sender; + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`imessage: failed updating session meta: ${String(err)}`); + updateLastRoute: + !isGroup && updateTarget + ? { + sessionKey: route.mainSessionKey, + channel: "imessage", + to: updateTarget, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logVerbose(`imessage: failed updating session meta: ${String(err)}`); + }, }); - if (!isGroup) { - const to = (isGroup ? chatTarget : undefined) || sender; - if (to) { - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "imessage", - to, - accountId: route.accountId, - }, - ctx: ctxPayload, - }); - } - } - if (shouldLogVerbose()) { const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n"); logVerbose( @@ -540,14 +528,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ); } - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const dispatcher = createReplyDispatcher({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { await deliverReplies({ @@ -565,7 +550,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }, }); - const { queuedFinal } = await dispatchReplyFromConfig({ + const { queuedFinal } = await dispatchInboundMessage({ ctx: ctxPayload, cfg, dispatcher, @@ -574,23 +559,21 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P typeof accountInfo.config.blockStreaming === "boolean" ? !accountInfo.config.blockStreaming : undefined, - onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; - }, + onModelSelected: prefixContext.onModelSelected, }, }); if (!queuedFinal) { - if (isGroup && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: groupHistories, historyKey }); + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: groupHistories, + historyKey, + limit: historyLimit, + }); } return; } - if (isGroup && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: groupHistories, historyKey }); + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } } diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index f5a1b6995..d10879008 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -39,7 +39,7 @@ export function formatUsageWindowSummary( snapshot: ProviderUsageSnapshot, opts?: { now?: number; maxWindows?: number; includeResets?: boolean }, ): string | null { - if (snapshot.error) return `error: ${snapshot.error}`; + if (snapshot.error) return null; if (snapshot.windows.length === 0) return null; const now = opts?.now ?? Date.now(); const maxWindows = diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index cb8d0be4e..410c7befd 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,8 +1,21 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js"; +import * as tailscale from "./tailscale.js"; + +const { + ensureGoInstalled, + ensureTailscaledInstalled, + getTailnetHostname, + enableTailscaleServe, + disableTailscaleServe, + ensureFunnel, +} = tailscale; describe("tailscale helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("parses DNS name from tailscale status", async () => { const exec = vi.fn().mockResolvedValue({ stdout: JSON.stringify({ @@ -48,4 +61,131 @@ describe("tailscale helpers", () => { await ensureTailscaledInstalled(exec as never, prompt, runtime); expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); }); + + it("enableTailscaleServe attempts normal first, then sudo", async () => { + // 1. First attempt fails + // 2. Second attempt (sudo) succeeds + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi + .fn() + .mockRejectedValueOnce(new Error("permission denied")) + .mockResolvedValueOnce({ stdout: "" }); + + await enableTailscaleServe(3000, exec as never); + + expect(exec).toHaveBeenNthCalledWith( + 1, + "tailscale", + expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), + expect.any(Object), + ); + + expect(exec).toHaveBeenNthCalledWith( + 2, + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "--bg", "--yes", "3000"]), + expect.any(Object), + ); + }); + + it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => { + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + + await enableTailscaleServe(3000, exec as never); + + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith( + "tailscale", + expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), + expect.any(Object), + ); + }); + + it("disableTailscaleServe uses fallback", async () => { + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi + .fn() + .mockRejectedValueOnce(new Error("permission denied")) + .mockResolvedValueOnce({ stdout: "" }); + + await disableTailscaleServe(exec as never); + + expect(exec).toHaveBeenCalledTimes(2); + expect(exec).toHaveBeenNthCalledWith( + 2, + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "reset"]), + expect.any(Object), + ); + }); + + it("ensureFunnel uses fallback for enabling", async () => { + // Mock exec: + // 1. status (success) + // 2. enable (fails) + // 3. enable sudo (success) + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi + .fn() + .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status + .mockRejectedValueOnce(new Error("permission denied")) // enable normal + .mockResolvedValueOnce({ stdout: "" }); // enable sudo + + const runtime = { + error: vi.fn(), + log: vi.fn(), + exit: vi.fn() as unknown as (code: number) => never, + }; + const prompt = vi.fn(); + + await ensureFunnel(8080, exec as never, runtime, prompt); + + // 1. status + expect(exec).toHaveBeenNthCalledWith( + 1, + "tailscale", + expect.arrayContaining(["funnel", "status", "--json"]), + ); + + // 2. enable normal + expect(exec).toHaveBeenNthCalledWith( + 2, + "tailscale", + expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]), + expect.any(Object), + ); + + // 3. enable sudo + expect(exec).toHaveBeenNthCalledWith( + 3, + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "--yes", "--bg", "8080"]), + expect.any(Object), + ); + }); + + it("enableTailscaleServe skips sudo on non-permission errors", async () => { + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi.fn().mockRejectedValueOnce(new Error("boom")); + + await expect(enableTailscaleServe(3000, exec as never)).rejects.toThrow("boom"); + + expect(exec).toHaveBeenCalledTimes(1); + }); + + it("enableTailscaleServe rethrows original error if sudo fails", async () => { + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const originalError = Object.assign(new Error("permission denied"), { + stderr: "permission denied", + }); + const exec = vi + .fn() + .mockRejectedValueOnce(originalError) + .mockRejectedValueOnce(new Error("sudo: a password is required")); + + await expect(enableTailscaleServe(3000, exec as never)).rejects.toBe(originalError); + + expect(exec).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 58d7b3f93..8ff340184 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -206,6 +206,64 @@ export async function ensureTailscaledInstalled( await exec("brew", ["install", "tailscale"]); } +type ExecErrorDetails = { + stdout?: unknown; + stderr?: unknown; + message?: unknown; + code?: unknown; +}; + +function extractExecErrorText(err: unknown) { + const errOutput = err as ExecErrorDetails; + const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; + const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : ""; + const message = typeof errOutput.message === "string" ? errOutput.message : ""; + const code = typeof errOutput.code === "string" ? errOutput.code : ""; + return { stdout, stderr, message, code }; +} + +function isPermissionDeniedError(err: unknown): boolean { + const { stdout, stderr, message, code } = extractExecErrorText(err); + if (code.toUpperCase() === "EACCES") return true; + const combined = `${stdout}\n${stderr}\n${message}`.toLowerCase(); + return ( + combined.includes("permission denied") || + combined.includes("access denied") || + combined.includes("operation not permitted") || + combined.includes("not permitted") || + combined.includes("requires root") || + combined.includes("must be run as root") || + combined.includes("must be run with sudo") || + combined.includes("requires sudo") || + combined.includes("need sudo") + ); +} + +// Helper to attempt a command, and retry with sudo if it fails. +async function execWithSudoFallback( + exec: typeof runExec, + bin: string, + args: string[], + opts: { maxBuffer?: number; timeoutMs?: number }, +): Promise<{ stdout: string; stderr: string }> { + try { + return await exec(bin, args, opts); + } catch (err) { + if (!isPermissionDeniedError(err)) { + throw err; + } + logVerbose(`Command failed, retrying with sudo: ${bin} ${args.join(" ")}`); + try { + return await exec("sudo", ["-n", bin, ...args], opts); + } catch (sudoErr) { + const { stderr, message } = extractExecErrorText(sudoErr); + const detail = (stderr || message).trim(); + if (detail) logVerbose(`Sudo retry failed: ${detail}`); + throw err; + } + } +} + export async function ensureFunnel( port: number, exec: typeof runExec = runExec, @@ -237,10 +295,16 @@ export async function ensureFunnel( } logVerbose(`Enabling funnel on port ${port}…`); - const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + // Attempt with fallback + const { stdout } = await execWithSudoFallback( + exec, + tailscaleBin, + ["funnel", "--yes", "--bg", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); if (stdout.trim()) console.log(stdout.trim()); } catch (err) { const errOutput = err as { stdout?: unknown; stderr?: unknown }; @@ -288,7 +352,7 @@ export async function ensureFunnel( export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], { + await execWithSudoFallback(exec, tailscaleBin, ["serve", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -296,7 +360,7 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec = export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["serve", "reset"], { + await execWithSudoFallback(exec, tailscaleBin, ["serve", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -304,7 +368,7 @@ export async function disableTailscaleServe(exec: typeof runExec = runExec) { export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], { + await execWithSudoFallback(exec, tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -312,7 +376,7 @@ export async function enableTailscaleFunnel(port: number, exec: typeof runExec = export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["funnel", "reset"], { + await execWithSudoFallback(exec, tailscaleBin, ["funnel", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index ba6239184..adcb93eca 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -197,17 +197,20 @@ export function logSessionStateChange( }, ) { const state = getSessionState(params); + const isProbeSession = state.sessionId?.startsWith("probe-") ?? false; const prevState = state.state; state.state = params.state; state.lastActivity = Date.now(); if (params.state === "idle") state.queueDepth = Math.max(0, state.queueDepth - 1); - diag.info( - `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${ - state.sessionKey ?? "unknown" - } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${ - state.queueDepth - }`, - ); + if (!isProbeSession) { + diag.info( + `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${ + state.sessionKey ?? "unknown" + } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${ + state.queueDepth + }`, + ); + } emitDiagnosticEvent({ type: "session.state", sessionId: state.sessionId, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 72bb72422..167838b52 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -108,8 +108,10 @@ export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js"; export { buildPendingHistoryContextFromMap, clearHistoryEntries, + clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, } from "../auto-reply/reply/history.js"; export type { HistoryEntry } from "../auto-reply/reply/history.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; @@ -117,9 +119,23 @@ export { resolveMentionGating, resolveMentionGatingWithBypass, } from "../channels/mention-gating.js"; +export type { + AckReactionGateParams, + AckReactionScope, + WhatsAppAckReactionMode, +} from "../channels/ack-reactions.js"; +export { + removeAckReactionAfterReply, + shouldAckReaction, + shouldAckReactionForWhatsApp, +} from "../channels/ack-reactions.js"; +export { createTypingCallbacks } from "../channels/typing.js"; +export { createReplyPrefixContext } from "../channels/reply-prefix.js"; +export { logAckFailure, logInboundDrop, logTypingFailure } from "../channels/logging.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; +export { resolveControlCommandGate } from "../channels/command-gating.js"; export { resolveBlueBubblesGroupRequireMention, resolveDiscordGroupRequireMention, @@ -128,6 +144,7 @@ export { resolveTelegramGroupRequireMention, resolveWhatsAppGroupRequireMention, } from "../channels/plugins/group-mentions.js"; +export { recordInboundSession } from "../channels/session.js"; export { buildChannelKeyCandidates, normalizeChannelSlug, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 6bb10984b..534d3361b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -25,7 +25,9 @@ import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../a import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; import { handleSlackAction } from "../../agents/tools/slack-actions.js"; import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js"; +import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { recordInboundSession } from "../../channels/session.js"; import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; @@ -192,12 +194,17 @@ export function createPluginRuntime(): PluginRuntime { resolveStorePath, readSessionUpdatedAt, recordSessionMetaFromInbound, + recordInboundSession, updateLastRoute, }, mentions: { buildMentionRegexes, matchesMentionPatterns, }, + reactions: { + shouldAckReaction, + removeAckReactionAfterReply, + }, groups: { resolveGroupPolicy: resolveChannelGroupPolicy, resolveRequireMention: resolveChannelGroupRequireMention, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 1f321d04b..3cda8ee51 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -19,6 +19,9 @@ type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer; type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes; type MatchesMentionPatterns = typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns; +type ShouldAckReaction = typeof import("../../channels/ack-reactions.js").shouldAckReaction; +type RemoveAckReactionAfterReply = + typeof import("../../channels/ack-reactions.js").removeAckReactionAfterReply; type ResolveChannelGroupPolicy = typeof import("../../config/group-policy.js").resolveChannelGroupPolicy; type ResolveChannelGroupRequireMention = @@ -51,6 +54,7 @@ type FormatInboundEnvelope = typeof import("../../auto-reply/envelope.js").forma type ResolveEnvelopeFormatOptions = typeof import("../../auto-reply/envelope.js").resolveEnvelopeFormatOptions; type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir; +type RecordInboundSession = typeof import("../../channels/session.js").recordInboundSession; type RecordSessionMetaFromInbound = typeof import("../../config/sessions.js").recordSessionMetaFromInbound; type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath; @@ -205,12 +209,17 @@ export type PluginRuntime = { resolveStorePath: ResolveStorePath; readSessionUpdatedAt: ReadSessionUpdatedAt; recordSessionMetaFromInbound: RecordSessionMetaFromInbound; + recordInboundSession: RecordInboundSession; updateLastRoute: UpdateLastRoute; }; mentions: { buildMentionRegexes: BuildMentionRegexes; matchesMentionPatterns: MatchesMentionPatterns; }; + reactions: { + shouldAckReaction: ShouldAckReaction; + removeAckReactionAfterReply: RemoveAckReactionAfterReply; + }; groups: { resolveGroupPolicy: ResolveChannelGroupPolicy; resolveRequireMention: ResolveChannelGroupRequireMention; diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index 9b203c938..2f2857130 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -68,9 +68,12 @@ function drainLane(lane: string) { entry.resolve(result); } catch (err) { state.active -= 1; - diag.error( - `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, - ); + const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-"); + if (!isProbeLane) { + diag.error( + `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, + ); + } pump(); entry.reject(err); } diff --git a/src/signal/monitor.event-handler.sender-prefix.test.ts b/src/signal/monitor.event-handler.sender-prefix.test.ts index 3c5569940..bf8ee7136 100644 --- a/src/signal/monitor.event-handler.sender-prefix.test.ts +++ b/src/signal/monitor.event-handler.sender-prefix.test.ts @@ -12,21 +12,21 @@ describe("signal event handler sender prefix", () => { beforeEach(() => { dispatchMock.mockReset().mockImplementation(async ({ dispatcher, ctx }) => { dispatcher.sendFinalReply({ text: "ok" }); - return { queuedFinal: true, counts: { final: 1 }, ctx }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 }, ctx }; }); readAllowFromMock.mockReset().mockResolvedValue([]); }); it("prefixes group bodies with sender label", async () => { let capturedBody = ""; - const dispatchModule = await import("../auto-reply/reply/dispatch-from-config.js"); - vi.spyOn(dispatchModule, "dispatchReplyFromConfig").mockImplementation( + const dispatchModule = await import("../auto-reply/dispatch.js"); + vi.spyOn(dispatchModule, "dispatchInboundMessage").mockImplementation( async (...args: unknown[]) => dispatchMock(...args), ); dispatchMock.mockImplementationOnce(async ({ dispatcher, ctx }) => { capturedBody = ctx.Body ?? ""; dispatcher.sendFinalReply({ text: "ok" }); - return { queuedFinal: true, counts: { final: 1 } }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; }); const { createSignalEventHandler } = await import("./monitor/event-handler.js"); diff --git a/src/signal/monitor.event-handler.typing-read-receipts.test.ts b/src/signal/monitor.event-handler.typing-read-receipts.test.ts index c4ba6f9ce..7aee1e24d 100644 --- a/src/signal/monitor.event-handler.typing-read-receipts.test.ts +++ b/src/signal/monitor.event-handler.typing-read-receipts.test.ts @@ -9,14 +9,21 @@ vi.mock("./send.js", () => ({ sendReadReceiptSignal: (...args: unknown[]) => sendReadReceiptMock(...args), })); -vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: vi.fn( +vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + const dispatchInboundMessage = vi.fn( async (params: { replyOptions?: { onReplyStart?: () => void } }) => { await Promise.resolve(params.replyOptions?.onReplyStart?.()); return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; }, - ), -})); + ); + return { + ...actual, + dispatchInboundMessage, + dispatchInboundMessageWithDispatcher: dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage, + }; +}); vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn().mockResolvedValue([]), @@ -25,11 +32,13 @@ vi.mock("../pairing/pairing-store.js", () => ({ describe("signal event handler typing + read receipts", () => { beforeEach(() => { + vi.useRealTimers(); sendTypingMock.mockReset().mockResolvedValue(true); sendReadReceiptMock.mockReset().mockResolvedValue(true); }); it("sends typing + read receipt for allowed DMs", async () => { + vi.resetModules(); const { createSignalEventHandler } = await import("./monitor/event-handler.js"); const handler = createSignalEventHandler({ runtime: { log: () => {}, error: () => {} } as any, diff --git a/src/signal/monitor/event-handler.inbound-contract.test.ts b/src/signal/monitor/event-handler.inbound-contract.test.ts index 9277eb990..d073357ff 100644 --- a/src/signal/monitor/event-handler.inbound-contract.test.ts +++ b/src/signal/monitor/event-handler.inbound-contract.test.ts @@ -5,17 +5,24 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont let capturedCtx: MsgContext | undefined; -vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => { +vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => { capturedCtx = params.ctx; - return { queuedFinal: false, counts: { tool: 0, block: 0 } }; - }), -})); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }); + return { + ...actual, + dispatchInboundMessage, + dispatchInboundMessageWithDispatcher: dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage, + }; +}); import { createSignalEventHandler } from "./event-handler.js"; describe("signal createSignalEventHandler inbound contract", () => { - it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => { + it("passes a finalized MsgContext to dispatchInboundMessage", async () => { capturedCtx = undefined; const handler = createSignalEventHandler({ diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 30fabedfb..72195ff78 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -1,12 +1,4 @@ -import { - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../auto-reply/reply/response-prefix-template.js"; +import { resolveHumanDelayConfig } from "../../agents/identity.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { formatInboundEnvelope, @@ -17,19 +9,18 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; -import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, } from "../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../../config/sessions.js"; +import { logInboundDrop, logTypingFailure } from "../../channels/logging.js"; +import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; +import { recordInboundSession } from "../../channels/session.js"; +import { createTypingCallbacks } from "../../channels/typing.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -40,7 +31,7 @@ import { } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { normalizeE164 } from "../../utils.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { formatSignalPairingIdLine, formatSignalSenderDisplay, @@ -111,7 +102,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }); let combinedBody = body; const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined; - if (entry.isGroup && historyKey && deps.historyLimit > 0) { + if (entry.isGroup && historyKey) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: deps.groupHistories, historyKey, @@ -159,53 +150,52 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { OriginatingTo: signalTo, }); - void recordSessionMetaFromInbound({ + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`signal: failed updating session meta: ${String(err)}`); + updateLastRoute: !entry.isGroup + ? { + sessionKey: route.mainSessionKey, + channel: "signal", + to: entry.senderRecipient, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logVerbose(`signal: failed updating session meta: ${String(err)}`); + }, }); - if (!entry.isGroup) { - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "signal", - to: entry.senderRecipient, - accountId: route.accountId, - }, - ctx: ctxPayload, - }); - } - if (shouldLogVerbose()) { const preview = body.slice(0, 200).replace(/\\n/g, "\\\\n"); logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`); } - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(deps.cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg: deps.cfg, agentId: route.agentId }); - const onReplyStart = async () => { - try { + const typingCallbacks = createTypingCallbacks({ + start: async () => { if (!ctxPayload.To) return; await sendTypingSignal(ctxPayload.To, { baseUrl: deps.baseUrl, account: deps.account, accountId: deps.accountId, }); - } catch (err) { - logVerbose(`signal typing cue failed for ${ctxPayload.To}: ${String(err)}`); - } - }; + }, + onStartError: (err) => { + logTypingFailure({ + log: logVerbose, + channel: "signal", + target: ctxPayload.To ?? undefined, + error: err, + }); + }, + }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId), deliver: async (payload) => { await deps.deliverReplies({ @@ -222,10 +212,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { onError: (err, info) => { deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart, + onReplyStart: typingCallbacks.onReplyStart, }); - const { queuedFinal } = await dispatchReplyFromConfig({ + const { queuedFinal } = await dispatchInboundMessage({ ctx: ctxPayload, cfg: deps.cfg, dispatcher, @@ -234,23 +224,27 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { disableBlockStreaming: typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + prefixContext.onModelSelected(ctx); }, }, }); markDispatchIdle(); if (!queuedFinal) { - if (entry.isGroup && historyKey && deps.historyLimit > 0) { - clearHistoryEntries({ historyMap: deps.groupHistories, historyKey }); + if (entry.isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: deps.groupHistories, + historyKey, + limit: deps.historyLimit, + }); } return; } - if (entry.isGroup && historyKey && deps.historyLimit > 0) { - clearHistoryEntries({ historyMap: deps.groupHistories, historyKey }); + if (entry.isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: deps.groupHistories, + historyKey, + limit: deps.historyLimit, + }); } } @@ -451,17 +445,24 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false; const ownerAllowedForCommands = isSignalSenderAllowed(sender, effectiveDmAllow); const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow); - const commandAuthorized = isGroup - ? resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands }, - { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, - ], - }) - : dmAllowed; - if (isGroup && hasControlCommand(messageText, deps.cfg) && !commandAuthorized) { - logVerbose(`signal: drop control command from unauthorized sender ${senderDisplay}`); + const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, + ], + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + }); + const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAllowed; + if (isGroup && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerbose, + channel: "signal", + reason: "control command (unauthorized)", + target: senderDisplay, + }); return; } diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index de1b1f267..d31885cfa 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -1,14 +1,10 @@ -import { - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../../auto-reply/reply/response-prefix-template.js"; -import { dispatchReplyFromConfig } from "../../../auto-reply/reply/dispatch-from-config.js"; -import { clearHistoryEntries } from "../../../auto-reply/reply/history.js"; +import { resolveHumanDelayConfig } from "../../../agents/identity.js"; +import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; +import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js"; +import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; +import { logAckFailure, logTypingFailure } from "../../../channels/logging.js"; +import { createReplyPrefixContext } from "../../../channels/reply-prefix.js"; +import { createTypingCallbacks } from "../../../channels/typing.js"; import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; @@ -60,23 +56,50 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag hasRepliedRef, }); - const onReplyStart = async () => { - didSetStatus = true; - await ctx.setSlackThreadStatus({ - channelId: message.channel, - threadTs: statusThreadTs, - status: "is typing...", - }); - }; + const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel; + const typingCallbacks = createTypingCallbacks({ + start: async () => { + didSetStatus = true; + await ctx.setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "is typing...", + }); + }, + stop: async () => { + if (!didSetStatus) return; + didSetStatus = false; + await ctx.setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "", + }); + }, + onStartError: (err) => { + logTypingFailure({ + log: (message) => runtime.error?.(danger(message)), + channel: "slack", + action: "start", + target: typingTarget, + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: (message) => runtime.error?.(danger(message)), + channel: "slack", + action: "stop", + target: typingTarget, + error: err, + }); + }, + }); - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { const replyThreadTs = replyPlan.nextThreadTs(); @@ -93,18 +116,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }, onError: (err, info) => { runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`)); - if (didSetStatus) { - void ctx.setSlackThreadStatus({ - channelId: message.channel, - threadTs: statusThreadTs, - status: "", - }); - } + typingCallbacks.onIdle?.(); }, - onReplyStart, + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, }); - const { queuedFinal, counts } = await dispatchReplyFromConfig({ + const { queuedFinal, counts } = await dispatchInboundMessage({ ctx: prepared.ctxPayload, cfg, dispatcher, @@ -117,29 +135,18 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ? !account.config.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + prefixContext.onModelSelected(ctx); }, }, }); markDispatchIdle(); - if (didSetStatus) { - await ctx.setSlackThreadStatus({ - channelId: message.channel, - threadTs: statusThreadTs, - status: "", - }); - } - if (!queuedFinal) { - if (prepared.isRoomish && ctx.historyLimit > 0) { - clearHistoryEntries({ + if (prepared.isRoomish) { + clearHistoryEntriesIfEnabled({ historyMap: ctx.channelHistories, historyKey: prepared.historyKey, + limit: ctx.historyLimit, }); } return; @@ -152,26 +159,35 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ); } - if (ctx.removeAckAfterReply && prepared.ackReactionPromise && prepared.ackReactionMessageTs) { - const messageTs = prepared.ackReactionMessageTs; - const ackValue = prepared.ackReactionValue; - void prepared.ackReactionPromise.then((didAck) => { - if (!didAck) return; - removeSlackReaction(message.channel, messageTs, ackValue, { - token: ctx.botToken, - client: ctx.app.client, - }).catch((err) => { - logVerbose( - `slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`, - ); + removeAckReactionAfterReply({ + removeAfterReply: ctx.removeAckAfterReply, + ackReactionPromise: prepared.ackReactionPromise, + ackReactionValue: prepared.ackReactionValue, + remove: () => + removeSlackReaction( + message.channel, + prepared.ackReactionMessageTs ?? "", + prepared.ackReactionValue, + { + token: ctx.botToken, + client: ctx.app.client, + }, + ), + onError: (err) => { + logAckFailure({ + log: logVerbose, + channel: "slack", + target: `${message.channel}/${message.ts}`, + error: err, }); - }); - } + }, + }); - if (prepared.isRoomish && ctx.historyLimit > 0) { - clearHistoryEntries({ + if (prepared.isRoomish) { + clearHistoryEntriesIfEnabled({ historyMap: ctx.channelHistories, historyKey: prepared.historyKey, + limit: ctx.historyLimit, }); } } diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 767ecea89..24c251d2a 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -9,7 +9,7 @@ import { } from "../../../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, } from "../../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../../auto-reply/reply/mentions.js"; @@ -19,15 +19,17 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js"; import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; +import { + shouldAckReaction as shouldAckReactionGate, + type AckReactionScope, +} from "../../../channels/ack-reactions.js"; import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js"; +import { logInboundDrop } from "../../../channels/logging.js"; import { formatAllowlistMatchMeta } from "../../../channels/allowlist-match.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, -} from "../../../config/sessions.js"; +import { recordInboundSession } from "../../../channels/session.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; @@ -264,7 +266,12 @@ export async function prepareSlackMessage(params: { const commandAuthorized = commandGate.commandAuthorized; if (isRoomish && commandGate.shouldBlock) { - logVerbose(`Blocked slack control command from unauthorized sender ${senderId}`); + logInboundDrop({ + log: logVerbose, + channel: "slack", + reason: "control command (unauthorized)", + target: senderId, + }); return null; } @@ -288,28 +295,26 @@ export async function prepareSlackMessage(params: { const effectiveWasMentioned = mentionGate.effectiveWasMentioned; if (isRoom && shouldRequireMention && mentionGate.shouldSkip) { ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message"); - if (ctx.historyLimit > 0) { - const pendingText = (message.text ?? "").trim(); - const fallbackFile = message.files?.[0]?.name - ? `[Slack file: ${message.files[0].name}]` - : message.files?.length - ? "[Slack file]" - : ""; - const pendingBody = pendingText || fallbackFile; - if (pendingBody) { - recordPendingHistoryEntry({ - historyMap: ctx.channelHistories, - historyKey, - limit: ctx.historyLimit, - entry: { + const pendingText = (message.text ?? "").trim(); + const fallbackFile = message.files?.[0]?.name + ? `[Slack file: ${message.files[0].name}]` + : message.files?.length + ? "[Slack file]" + : ""; + const pendingBody = pendingText || fallbackFile; + recordPendingHistoryEntryIfEnabled({ + historyMap: ctx.channelHistories, + historyKey, + limit: ctx.historyLimit, + entry: pendingBody + ? { sender: senderName, body: pendingBody, timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, messageId: message.ts, - }, - }); - } - } + } + : null, + }); return null; } @@ -324,19 +329,20 @@ export async function prepareSlackMessage(params: { const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReactionValue = ackReaction ?? ""; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ctx.ackReactionScope === "all") return true; - if (ctx.ackReactionScope === "direct") return isDirectMessage; - if (ctx.ackReactionScope === "group-all") return isRoomish; - if (ctx.ackReactionScope === "group-mentions") { - if (!isRoom) return false; - if (!shouldRequireMention) return false; - if (!canDetectMention) return false; - return effectiveWasMentioned; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReaction && + shouldAckReactionGate({ + scope: ctx.ackReactionScope as AckReactionScope | undefined, + isDirect: isDirectMessage, + isGroup: isRoomish, + isMentionableGroup: isRoom, + requireMention: Boolean(shouldRequireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention: mentionGate.shouldBypassMention, + }), + ); const ackReactionMessageTs = message.ts; const ackReactionPromise = @@ -508,19 +514,28 @@ export async function prepareSlackMessage(params: { OriginatingTo: slackTo, }) satisfies FinalizedMsgContext; - void recordSessionMetaFromInbound({ + await recordInboundSession({ storePath, - sessionKey: sessionKey, + sessionKey, ctx: ctxPayload, - }).catch((err) => { - ctx.logger.warn( - { - error: String(err), - storePath, - sessionKey, - }, - "failed updating session meta", - ); + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "slack", + to: `user:${message.user}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + ctx.logger.warn( + { + error: String(err), + storePath, + sessionKey, + }, + "failed updating session meta", + ); + }, }); const replyTarget = ctxPayload.To ?? undefined; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index dc8039a18..e4a7d6780 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -6,26 +6,24 @@ import { normalizeCommandBody } from "../auto-reply/commands-registry.js"; import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, type HistoryEntry, } from "../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; +import { recordInboundSession } from "../channels/session.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../config/sessions.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; +import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js"; import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; import { resolveControlCommandGate } from "../channels/command-gating.js"; +import { logInboundDrop } from "../channels/logging.js"; import { buildGroupLabel, buildSenderLabel, @@ -159,11 +157,7 @@ export const buildTelegramMessageContext = async ({ } const sendTyping = async () => { - try { - await bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId)); - } catch (err) { - logVerbose(`telegram typing cue failed for chat ${chatId}: ${String(err)}`); - } + await bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId)); }; const sendRecordVoice = async () => { @@ -313,7 +307,12 @@ export const buildTelegramMessageContext = async ({ (ent) => ent.type === "mention", ); if (isGroup && commandGate.shouldBlock) { - logVerbose(`telegram: drop control command from unauthorized sender ${senderId ?? "unknown"}`); + logInboundDrop({ + log: logVerbose, + channel: "telegram", + reason: "control command (unauthorized)", + target: senderId ?? "unknown", + }); return null; } const activationOverride = resolveGroupActivation({ @@ -349,19 +348,19 @@ export const buildTelegramMessageContext = async ({ if (isGroup && requireMention && canDetectMention) { if (mentionGate.shouldSkip) { logger.info({ chatId, reason: "no-mention" }, "skipping group message"); - if (historyKey && historyLimit > 0) { - recordPendingHistoryEntry({ - historyMap: groupHistories, - historyKey, - limit: historyLimit, - entry: { - sender: buildSenderLabel(msg, senderId || chatId), - body: rawBody, - timestamp: msg.date ? msg.date * 1000 : undefined, - messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined, - }, - }); - } + recordPendingHistoryEntryIfEnabled({ + historyMap: groupHistories, + historyKey: historyKey ?? "", + limit: historyLimit, + entry: historyKey + ? { + sender: buildSenderLabel(msg, senderId || chatId), + body: rawBody, + timestamp: msg.date ? msg.date * 1000 : undefined, + messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined, + } + : null, + }); return null; } } @@ -369,19 +368,20 @@ export const buildTelegramMessageContext = async ({ // ACK reactions const ackReaction = resolveAckReaction(cfg, route.agentId); const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return !isGroup; - if (ackReactionScope === "group-all") return isGroup; - if (ackReactionScope === "group-mentions") { - if (!isGroup) return false; - if (!requireMention) return false; - if (!canDetectMention) return false; - return effectiveWasMentioned; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReaction && + shouldAckReactionGate({ + scope: ackReactionScope, + isDirect: !isGroup, + isGroup, + isMentionableGroup: isGroup, + requireMention: Boolean(requireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention: mentionGate.shouldBypassMention, + }), + ); const api = bot.api as unknown as { setMessageReaction?: ( chatId: number | string, @@ -517,12 +517,21 @@ export const buildTelegramMessageContext = async ({ OriginatingTo: `telegram:${chatId}`, }); - void recordSessionMetaFromInbound({ + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`telegram: failed updating session meta: ${String(err)}`); + updateLastRoute: !isGroup + ? { + sessionKey: route.mainSessionKey, + channel: "telegram", + to: String(chatId), + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logVerbose(`telegram: failed updating session meta: ${String(err)}`); + }, }); if (replyTarget && shouldLogVerbose()) { @@ -538,19 +547,6 @@ export const buildTelegramMessageContext = async ({ ); } - if (!isGroup) { - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "telegram", - to: String(chatId), - accountId: route.accountId, - }, - ctx: ctxPayload, - }); - } - if (shouldLogVerbose()) { const preview = body.slice(0, 200).replace(/\n/g, "\\n"); const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : ""; diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 4afbaa653..98c5a6d40 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -1,12 +1,11 @@ // @ts-nocheck -import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../auto-reply/reply/response-prefix-template.js"; import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; -import { clearHistoryEntries } from "../auto-reply/reply/history.js"; +import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; +import { removeAckReactionAfterReply } from "../channels/ack-reactions.js"; +import { logAckFailure, logTypingFailure } from "../channels/logging.js"; +import { createReplyPrefixContext } from "../channels/reply-prefix.js"; +import { createTypingCallbacks } from "../channels/typing.js"; import { danger, logVerbose } from "../globals.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { deliverReplies } from "./bot/delivery.js"; @@ -120,10 +119,7 @@ export const dispatchTelegramMessage = async ({ Boolean(draftStream) || (typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined); - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", @@ -134,8 +130,8 @@ export const dispatchTelegramMessage = async ({ ctx: ctxPayload, cfg, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload, info) => { if (info.kind === "final") { await flushDraft(); @@ -157,7 +153,17 @@ export const dispatchTelegramMessage = async ({ onError: (err, info) => { runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: sendTyping, + onReplyStart: createTypingCallbacks({ + start: sendTyping, + onStartError: (err) => { + logTypingFailure({ + log: logVerbose, + channel: "telegram", + target: String(chatId), + error: err, + }); + }, + }).onReplyStart, }, replyOptions: { skillFilter, @@ -169,32 +175,33 @@ export const dispatchTelegramMessage = async ({ : undefined, disableBlockStreaming, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + prefixContext.onModelSelected(ctx); }, }, }); draftStream?.stop(); if (!queuedFinal) { - if (isGroup && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: groupHistories, historyKey }); + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } return; } - if (removeAckAfterReply && ackReactionPromise && msg.message_id && reactionApi) { - void ackReactionPromise.then((didAck) => { - if (!didAck) return; - reactionApi(chatId, msg.message_id, []).catch((err) => { - logVerbose( - `telegram: failed to remove ack reaction from ${chatId}/${msg.message_id}: ${String(err)}`, - ); + removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReactionPromise ? "ack" : null, + remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(), + onError: (err) => { + if (!msg.message_id) return; + logAckFailure({ + log: logVerbose, + channel: "telegram", + target: `${chatId}/${msg.message_id}`, + error: err, }); - }); - } - if (isGroup && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: groupHistories, historyKey }); + }, + }); + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } }; diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 4fea3521a..1a6afa519 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -22,6 +27,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -111,7 +124,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -121,7 +134,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts index 2afe8cd1c..0cda853be 100644 --- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts +++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +132,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts index 6c712ca1d..0aa431d1b 100644 --- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +132,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts index 9ed0ed677..8ed8e189f 100644 --- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts +++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +132,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index ab43c4269..ebbd3b092 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -1,9 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-throttler-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -23,6 +28,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -114,7 +127,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -125,7 +138,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts index dfdcf43e3..805aa34da 100644 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +132,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts index 1e1174fbf..ec81283bb 100644 --- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +132,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 6e83c61c3..fd9401dac 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +132,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts index 74f87d63b..80c880b79 100644 --- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts +++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts @@ -2,8 +2,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-reply-threading-${Math.random() + .toString(16) + .slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -24,6 +31,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -113,7 +128,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -122,7 +137,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 51beb4f4b..d4cdfaf4b 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -6,18 +6,24 @@ import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; +import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; +import { resolveTelegramFetch } from "./fetch.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +let replyModule: typeof import("../auto-reply/reply.js"); const { listSkillCommandsForAgents } = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); vi.mock("../auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents, })); -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import * as replyModule from "../auto-reply/reply.js"; -import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; -import { resolveTelegramFetch } from "./fetch.js"; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-bot-${Math.random().toString(16).slice(2)}.json`, +})); function resolveSkillCommands(config: Parameters[0]) { return listSkillCommandsForAgents({ cfg: config }); @@ -42,6 +48,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ @@ -155,7 +169,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 7bedb4d62..a14172809 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -408,6 +408,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { case "new": case "reset": try { + // Clear token counts immediately to avoid stale display (#1523) + state.sessionInfo.inputTokens = null; + state.sessionInfo.outputTokens = null; + state.sessionInfo.totalTokens = null; + tui.requestRender(); + await client.resetSession(state.currentSessionKey); chatLog.addSystem(`session ${state.currentSessionKey} reset`); await loadHistory(); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 3f8e2befd..148dca67a 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -1,6 +1,6 @@ import type { TUI } from "@mariozechner/pi-tui"; import type { ChatLog } from "./components/chat-log.js"; -import { asString } from "./tui-formatters.js"; +import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import { TuiStreamAssembler } from "./tui-stream-assembler.js"; import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js"; @@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) { setActivityStatus("streaming"); } if (evt.state === "final") { + if (isCommandMessage(evt.message)) { + const text = extractTextFromMessage(evt.message); + if (text) chatLog.addSystem(text); + streamAssembler.drop(evt.runId); + noteFinalizedRun(evt.runId); + state.activeChatRunId = null; + setActivityStatus("idle"); + void refreshSessionInfo?.(); + tui.requestRender(); + return; + } const stopReason = evt.message && typeof evt.message === "object" && !Array.isArray(evt.message) ? typeof (evt.message as Record).stopReason === "string" diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 541c58727..3200b237a 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -4,6 +4,7 @@ import { extractContentFromMessage, extractTextFromMessage, extractThinkingFromMessage, + isCommandMessage, } from "./tui-formatters.js"; describe("extractTextFromMessage", () => { @@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => { expect(text).toContain("HTTP 429"); }); }); + +describe("isCommandMessage", () => { + it("detects command-marked messages", () => { + expect(isCommandMessage({ command: true })).toBe(true); + expect(isCommandMessage({ command: false })).toBe(false); + expect(isCommandMessage({})).toBe(false); + }); +}); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 11e8e68c9..f77eb9ff1 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -140,6 +140,11 @@ export function extractTextFromMessage( return formatRawAssistantErrorForUi(errorMessage); } +export function isCommandMessage(message: unknown): boolean { + if (!message || typeof message !== "object") return false; + return (message as Record).command === true; +} + export function formatTokens(total?: number | null, context?: number | null) { if (total == null && context == null) return "tokens ?"; const totalLabel = total == null ? "?" : formatTokenCount(total); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 327363653..5dc6696ad 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -6,7 +6,7 @@ import { } from "../routing/session-key.js"; import type { ChatLog } from "./components/chat-log.js"; import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; -import { asString, extractTextFromMessage } from "./tui-formatters.js"; +import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; type SessionActionContext = { @@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) { for (const entry of record.messages ?? []) { if (!entry || typeof entry !== "object") continue; const message = entry as Record; + if (isCommandMessage(message)) { + const text = extractTextFromMessage(message); + if (text) chatLog.addSystem(text); + continue; + } if (message.role === "user") { const text = extractTextFromMessage(message); if (text) chatLog.addUser(text); diff --git a/src/web/auto-reply/monitor/ack-reaction.ts b/src/web/auto-reply/monitor/ack-reaction.ts index 58a2504cd..6a99da312 100644 --- a/src/web/auto-reply/monitor/ack-reaction.ts +++ b/src/web/auto-reply/monitor/ack-reaction.ts @@ -1,5 +1,6 @@ import type { loadConfig } from "../../../config/config.js"; import { logVerbose } from "../../../globals.js"; +import { shouldAckReactionForWhatsApp } from "../../../channels/ack-reactions.js"; import { sendReactionWhatsApp } from "../../outbound.js"; import { formatError } from "../../session.js"; import type { WebInboundMsg } from "../types.js"; @@ -24,30 +25,25 @@ export function maybeSendAckReaction(params: { const groupMode = ackConfig?.group ?? "mentions"; const conversationIdForCheck = params.msg.conversationId ?? params.msg.from; - const shouldSendReaction = () => { - if (!emoji) return false; - - if (params.msg.chatType === "direct") { - return directEnabled; - } - - if (params.msg.chatType === "group") { - if (groupMode === "never") return false; - if (groupMode === "always") return true; - if (groupMode === "mentions") { - const activation = resolveGroupActivationFor({ + const activation = + params.msg.chatType === "group" + ? resolveGroupActivationFor({ cfg: params.cfg, agentId: params.agentId, sessionKey: params.sessionKey, conversationId: conversationIdForCheck, - }); - if (activation === "always") return true; - return params.msg.wasMentioned === true; - } - } - - return false; - }; + }) + : null; + const shouldSendReaction = () => + shouldAckReactionForWhatsApp({ + emoji, + isDirect: params.msg.chatType === "direct", + isGroup: params.msg.chatType === "group", + directEnabled, + groupMode, + wasMentioned: params.msg.wasMentioned === true, + groupActivated: activation === "always", + }); if (!shouldSendReaction()) return; diff --git a/src/web/auto-reply/monitor/group-gating.ts b/src/web/auto-reply/monitor/group-gating.ts index a4e46d41e..8d1a33645 100644 --- a/src/web/auto-reply/monitor/group-gating.ts +++ b/src/web/auto-reply/monitor/group-gating.ts @@ -6,7 +6,7 @@ import { resolveMentionGating } from "../../../channels/mention-gating.js"; import type { MentionConfig } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; -import { recordPendingHistoryEntry } from "../../../auto-reply/reply/history.js"; +import { recordPendingHistoryEntryIfEnabled } from "../../../auto-reply/reply/history.js"; import { stripMentionsForCommand } from "./commands.js"; import { resolveGroupActivationFor, resolveGroupPolicyFor } from "./group-activation.js"; import { noteGroupMember } from "./group-members.js"; @@ -66,24 +66,22 @@ export function applyGroupGating(params: { if (activationCommand.hasCommand && !owner) { params.logVerbose(`Ignoring /activation from non-owner in group ${params.conversationId}`); - if (params.groupHistoryLimit > 0) { - const sender = - params.msg.senderName && params.msg.senderE164 - ? `${params.msg.senderName} (${params.msg.senderE164})` - : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown"); - recordPendingHistoryEntry({ - historyMap: params.groupHistories, - historyKey: params.groupHistoryKey, - limit: params.groupHistoryLimit, - entry: { - sender, - body: params.msg.body, - timestamp: params.msg.timestamp, - id: params.msg.id, - senderJid: params.msg.senderJid, - }, - }); - } + const sender = + params.msg.senderName && params.msg.senderE164 + ? `${params.msg.senderName} (${params.msg.senderE164})` + : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown"); + recordPendingHistoryEntryIfEnabled({ + historyMap: params.groupHistories, + historyKey: params.groupHistoryKey, + limit: params.groupHistoryLimit, + entry: { + sender, + body: params.msg.body, + timestamp: params.msg.timestamp, + id: params.msg.id, + senderJid: params.msg.senderJid, + }, + }); return { shouldProcess: false }; } @@ -126,24 +124,22 @@ export function applyGroupGating(params: { params.logVerbose( `Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`, ); - if (params.groupHistoryLimit > 0) { - const sender = - params.msg.senderName && params.msg.senderE164 - ? `${params.msg.senderName} (${params.msg.senderE164})` - : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown"); - recordPendingHistoryEntry({ - historyMap: params.groupHistories, - historyKey: params.groupHistoryKey, - limit: params.groupHistoryLimit, - entry: { - sender, - body: params.msg.body, - timestamp: params.msg.timestamp, - id: params.msg.id, - senderJid: params.msg.senderJid, - }, - }); - } + const sender = + params.msg.senderName && params.msg.senderE164 + ? `${params.msg.senderName} (${params.msg.senderE164})` + : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown"); + recordPendingHistoryEntryIfEnabled({ + historyMap: params.groupHistories, + historyKey: params.groupHistoryKey, + limit: params.groupHistoryLimit, + entry: { + sender, + body: params.msg.body, + timestamp: params.msg.timestamp, + id: params.msg.id, + senderJid: params.msg.senderJid, + }, + }); return { shouldProcess: false }; } diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index c1d280a65..57ad5448f 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -1,12 +1,4 @@ -import { - resolveEffectiveMessagesConfig, - resolveIdentityName, - resolveIdentityNamePrefix, -} from "../../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../../auto-reply/reply/response-prefix-template.js"; +import { resolveIdentityNamePrefix } from "../../../agents/identity.js"; import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; import { formatInboundEnvelope, @@ -22,6 +14,7 @@ import type { ReplyPayload } from "../../../auto-reply/types.js"; import { shouldComputeCommandAuthorized } from "../../../auto-reply/command-detection.js"; import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { toLocationContext } from "../../../channels/location.js"; +import { createReplyPrefixContext } from "../../../channels/reply-prefix.js"; import type { loadConfig } from "../../../config/config.js"; import { readSessionUpdatedAt, @@ -247,22 +240,20 @@ export async function processMessage(params: { ? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, msg: params.msg }) : undefined; const configuredResponsePrefix = params.cfg.messages?.responsePrefix; - const resolvedMessages = resolveEffectiveMessagesConfig(params.cfg, params.route.agentId); + const prefixContext = createReplyPrefixContext({ + cfg: params.cfg, + agentId: params.route.agentId, + }); const isSelfChat = params.msg.chatType !== "group" && Boolean(params.msg.selfE164) && normalizeE164(params.msg.from) === normalizeE164(params.msg.selfE164 ?? ""); const responsePrefix = - resolvedMessages.responsePrefix ?? + prefixContext.responsePrefix ?? (configuredResponsePrefix === undefined && isSelfChat ? (resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[clawdbot]") : undefined); - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(params.cfg, params.route.agentId), - }; - const ctxPayload = finalizeInboundContext({ Body: combinedBody, RawBody: params.msg.body, @@ -334,7 +325,7 @@ export async function processMessage(params: { replyResolver: params.replyResolver, dispatcherOptions: { responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, onHeartbeatStrip: () => { if (!didLogHeartbeatStrip) { didLogHeartbeatStrip = true; @@ -393,13 +384,7 @@ export async function processMessage(params: { typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean" ? !params.cfg.channels.whatsapp.blockStreaming : undefined, - onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; - }, + onModelSelected: prefixContext.onModelSelected, }, });