Merge branch 'openclaw:main' into codex/cortex-openclaw-integration

This commit is contained in:
Marc J Saint-jour 2026-03-17 22:45:24 -04:00 committed by GitHub
commit 3ecc16c324
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
150 changed files with 4220 additions and 2229 deletions

View File

@ -40,12 +40,6 @@ Docs: https://docs.openclaw.ai
- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo.
- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant.
### Breaking
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
### Fixes
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
@ -128,9 +122,6 @@ Docs: https://docs.openclaw.ai
- Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr.
- Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman.
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
### Fixes
- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus.
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
- macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67.
@ -141,6 +132,12 @@ Docs: https://docs.openclaw.ai
- Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus.
- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob.
### Breaking
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
## 2026.3.13
### Changes
@ -218,6 +215,7 @@ Docs: https://docs.openclaw.ai
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
## 2026.3.12
@ -647,6 +645,7 @@ Docs: https://docs.openclaw.ai
- Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.
- Web UI/config form: treat `additionalProperties: true` object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.
- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
- macOS/tray menu: keep injected sessions and device rows below the controls section so toggles and action buttons stay visible even when many sessions are active. (#38079) Thanks @bernesto.
- Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so `requireMention` checks compare against current bot identity instead of stale config names, fixing missed `@bot` handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.
- Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd.
- Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd.

View File

@ -1099,38 +1099,33 @@ extension MenuSessionsInjector {
// MARK: - Width + placement
private func findInsertIndex(in menu: NSMenu) -> Int? {
// Insert right before the separator above "Send Heartbeats".
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
return idx
}
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
if menu.items.count >= 1 { return 1 }
return menu.items.count
self.findDynamicSectionInsertIndex(in: menu)
}
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
return idx
self.findDynamicSectionInsertIndex(in: menu)
}
private func findDynamicSectionInsertIndex(in menu: NSMenu) -> Int? {
// Keep controls and action buttons visible by inserting dynamic rows at the
// built-in footer boundary, not by matching localized menu item titles.
if let footerSeparatorIndex = menu.items.lastIndex(where: { item in
item.isSeparatorItem && !self.isInjectedItem(item)
}) {
return footerSeparatorIndex
}
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
return sepIdx
if let firstBaseItemIndex = menu.items.firstIndex(where: { !self.isInjectedItem($0) }) {
return min(firstBaseItemIndex + 1, menu.items.count)
}
if menu.items.count >= 1 { return 1 }
return menu.items.count
}
private func isInjectedItem(_ item: NSMenuItem) -> Bool {
item.tag == self.tag || item.tag == self.nodesTag
}
private func initialWidth(for menu: NSMenu) -> CGFloat {
if let openWidth = self.menuOpenWidth {
return max(300, openWidth)
@ -1236,5 +1231,13 @@ extension MenuSessionsInjector {
func injectForTesting(into menu: NSMenu) {
self.inject(into: menu)
}
func testingFindInsertIndex(in menu: NSMenu) -> Int? {
self.findInsertIndex(in: menu)
}
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
self.findNodesInsertIndex(in: menu)
}
}
#endif

View File

@ -5,7 +5,26 @@ import Testing
@Suite(.serialized)
@MainActor
struct MenuSessionsInjectorTests {
@Test func `injects disconnected message`() {
@Test func anchorsDynamicRowsBelowControlsAndActions() throws {
let injector = MenuSessionsInjector()
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem }))
#expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex)
#expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex)
}
@Test func injectsDisconnectedMessage() {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(false)
injector.setTestingSnapshot(nil, errorText: nil)
@ -19,7 +38,7 @@ struct MenuSessionsInjectorTests {
#expect(menu.items.contains { $0.tag == 9_415_557 })
}
@Test func `injects session rows`() {
@Test func injectsSessionRows() throws {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(true)
@ -88,10 +107,22 @@ struct MenuSessionsInjectorTests {
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
injector.injectForTesting(into: menu)
#expect(menu.items.contains { $0.tag == 9_415_557 })
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }))
let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" }))
let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 }))
let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" }))
#expect(sendHeartbeatsIndex < firstInjectedIndex)
#expect(openDashboardIndex < firstInjectedIndex)
#expect(firstInjectedIndex < settingsIndex)
}
@Test func `cost usage submenu does not use injector delegate`() {

View File

@ -23200,6 +23200,56 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry",
"kind": "channel",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry.maxRetries",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry.timeoutMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmPolicy",
"kind": "channel",
@ -23709,6 +23759,56 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmChannelRetry",
"kind": "channel",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "channels.mattermost.dmChannelRetry.initialDelayMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmChannelRetry.maxDelayMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmChannelRetry.maxRetries",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmChannelRetry.timeoutMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmPolicy",
"kind": "channel",
@ -37601,12 +37701,13 @@
"path": "channels.zalouser.accounts.*.groupPolicy",
"kind": "channel",
"type": "string",
"required": false,
"required": true,
"enumValues": [
"open",
"disabled",
"allowlist"
],
"defaultValue": "allowlist",
"deprecated": false,
"sensitive": false,
"tags": [],
@ -37903,12 +38004,13 @@
"path": "channels.zalouser.groupPolicy",
"kind": "channel",
"type": "string",
"required": false,
"required": true,
"enumValues": [
"open",
"disabled",
"allowlist"
],
"defaultValue": "allowlist",
"deprecated": false,
"sensitive": false,
"tags": [],
@ -39699,7 +39801,7 @@
"network"
],
"label": "Control UI Allowed Origins",
"help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.",
"help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.",
"hasChildren": true
},
{
@ -41038,7 +41140,7 @@
"access"
],
"label": "Hooks Allowed Agent IDs",
"help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.",
"help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.",
"hasChildren": true
},
{
@ -42156,7 +42258,7 @@
"security"
],
"label": "Hooks Auth Token",
"help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.",
"help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.",
"hasChildren": false
},
{
@ -46269,6 +46371,127 @@
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.chutes",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/chutes-provider",
"help": "OpenClaw Chutes.ai provider plugin (plugin: chutes)",
"hasChildren": true
},
{
"path": "plugins.entries.chutes.config",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/chutes-provider Config",
"help": "Plugin-defined config payload for chutes.",
"hasChildren": false
},
{
"path": "plugins.entries.chutes.enabled",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Enable @openclaw/chutes-provider",
"hasChildren": false
},
{
"path": "plugins.entries.chutes.hooks",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Hook Policy",
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
"hasChildren": true
},
{
"path": "plugins.entries.chutes.hooks.allowPromptInjection",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Prompt Injection Hooks",
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"hasChildren": false
},
{
"path": "plugins.entries.chutes.subagent",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Subagent Policy",
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
"hasChildren": true
},
{
"path": "plugins.entries.chutes.subagent.allowedModels",
"kind": "plugin",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Plugin Subagent Allowed Models",
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
"hasChildren": true
},
{
"path": "plugins.entries.chutes.subagent.allowedModels.*",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "plugins.entries.chutes.subagent.allowModelOverride",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Plugin Subagent Model Override",
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.cloudflare-ai-gateway",
"kind": "plugin",

View File

@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5457}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5476}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@ -2086,6 +2086,11 @@
{"recordType":"path","path":"channels.mattermost.accounts.*.commands.nativeSkills","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@ -2130,6 +2135,11 @@
{"recordType":"path","path":"channels.mattermost.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Config Writes","help":"Allow Mattermost to write config in response to channel events/commands (default: true).","hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@ -3398,7 +3408,7 @@
{"recordType":"path","path":"channels.zalouser.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.accounts.*.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@ -3426,7 +3436,7 @@
{"recordType":"path","path":"channels.zalouser.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@ -3563,7 +3573,7 @@
{"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false}
{"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false}
{"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true}
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true}
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.","hasChildren":true}
{"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"gateway.controlUi.allowInsecureAuth","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access","advanced","network","security"],"label":"Insecure Control UI Auth Toggle","help":"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.","hasChildren":false}
{"recordType":"path","path":"gateway.controlUi.basePath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network","storage"],"label":"Control UI Base Path","help":"Optional URL prefix where the Control UI is served (e.g. /openclaw).","hasChildren":false}
@ -3667,7 +3677,7 @@
{"recordType":"path","path":"gateway.trustedProxies","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Trusted Proxy CIDRs","help":"CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.","hasChildren":true}
{"recordType":"path","path":"gateway.trustedProxies.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"hooks","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks","help":"Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.","hasChildren":true}
{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.","hasChildren":true}
{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.","hasChildren":true}
{"recordType":"path","path":"hooks.allowedAgentIds.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"hooks.allowedSessionKeyPrefixes","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","storage"],"label":"Hooks Allowed Session Key Prefixes","help":"Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.","hasChildren":true}
{"recordType":"path","path":"hooks.allowedSessionKeyPrefixes.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@ -3754,7 +3764,7 @@
{"recordType":"path","path":"hooks.path","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Endpoint Path","help":"HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.","hasChildren":false}
{"recordType":"path","path":"hooks.presets","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks Presets","help":"Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.","hasChildren":true}
{"recordType":"path","path":"hooks.presets.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false}
{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false}
{"recordType":"path","path":"hooks.transformsDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Transforms Directory","help":"Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.","hasChildren":false}
{"recordType":"path","path":"logging","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Logging","help":"Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.","hasChildren":true}
{"recordType":"path","path":"logging.consoleLevel","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"Console Log Level","help":"Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.","hasChildren":false}
@ -4093,6 +4103,15 @@
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.chutes","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider","help":"OpenClaw Chutes.ai provider plugin (plugin: chutes)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.chutes.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider Config","help":"Plugin-defined config payload for chutes.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.chutes.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/chutes-provider","hasChildren":false}
{"recordType":"path","path":"plugins.entries.chutes.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.chutes.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.chutes.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false}

View File

@ -176,19 +176,31 @@ openclaw config set channels.discord.token \
--ref-id DISCORD_BOT_TOKEN \
--dry-run \
--json
openclaw config set channels.discord.token \
--ref-provider vault \
--ref-source exec \
--ref-id discord/token \
--dry-run \
--allow-exec
```
Dry-run behavior:
- Builder mode: requires full SecretRef resolvability for changed refs/providers.
- JSON mode (`--strict-json`, `--json`, or batch mode): requires full resolvability and schema validation.
- Builder mode: runs SecretRef resolvability checks for changed refs/providers.
- JSON mode (`--strict-json`, `--json`, or batch mode): runs schema validation plus SecretRef resolvability checks.
- Exec SecretRef checks are skipped by default during dry-run to avoid command side effects.
- Use `--allow-exec` with `--dry-run` to opt in to exec SecretRef checks (this may execute provider commands).
- `--allow-exec` is dry-run only and errors if used without `--dry-run`.
`--dry-run --json` prints a machine-readable report:
- `ok`: whether dry-run passed
- `operations`: number of assignments evaluated
- `checks`: whether schema/resolvability checks ran
- `refsChecked`: number of refs resolved during dry-run
- `checks.resolvabilityComplete`: whether resolvability checks ran to completion (false when exec refs are skipped)
- `refsChecked`: number of refs actually resolved during dry-run
- `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set
- `errors`: structured schema/resolvability failures when `ok=false`
### JSON Output Shape
@ -202,8 +214,10 @@ Dry-run behavior:
checks: {
schema: boolean,
resolvability: boolean,
resolvabilityComplete: boolean,
},
refsChecked: number,
skippedExecRefs: number,
errors?: [
{
kind: "schema" | "resolvability",
@ -224,9 +238,11 @@ Success example:
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true
"resolvability": true,
"resolvabilityComplete": true
},
"refsChecked": 1
"refsChecked": 1,
"skippedExecRefs": 0
}
```
@ -240,9 +256,11 @@ Failure example:
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true
"resolvability": true,
"resolvabilityComplete": true
},
"refsChecked": 1,
"skippedExecRefs": 0,
"errors": [
{
"kind": "resolvability",
@ -257,6 +275,7 @@ If dry-run fails:
- `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape.
- `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch).
- `Dry run note: skipped <n> exec SecretRef resolvability check(s)`: dry-run skipped exec refs; rerun with `--allow-exec` if you need exec resolvability validation.
- For batch mode, fix failing entries and rerun `--dry-run` before writing.
## Subcommands

View File

@ -400,8 +400,9 @@ Subcommands:
- SecretRef builder mode: `config set <path> --ref-provider <provider> --ref-source <source> --ref-id <id>`
- provider builder mode: `config set secrets.providers.<alias> --provider-source <env|file|exec> ...`
- batch mode: `config set --batch-json '<json>'` or `config set --batch-file <path>`
- `config set --dry-run`: validate assignments without writing `openclaw.json`.
- `config set --dry-run --json`: emit machine-readable dry-run output (checks, operations, errors).
- `config set --dry-run`: validate assignments without writing `openclaw.json` (exec SecretRef checks are skipped by default).
- `config set --allow-exec --dry-run`: opt in to exec SecretRef dry-run checks (may execute provider commands).
- `config set --dry-run --json`: emit machine-readable dry-run output (checks + completeness signal, operations, refs checked/skipped, errors).
- `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode.
- `config unset <path>`: remove a value.
- `config file`: print the active config file path.

View File

@ -52,6 +52,17 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Runs in CI
- No real keys required
- Should be fast and stable
- Embedded runner note:
- When you change message-tool discovery inputs or compaction runtime context,
keep both levels of coverage.
- Add focused helper regressions for pure routing/normalization seams.
- Also keep the embedded runner integration suites healthy:
`src/agents/pi-embedded-runner/compact.hooks.test.ts`,
`src/agents/pi-embedded-runner/run.overflow-compaction.test.ts`, and
`src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts`.
- Those suites verify that scoped ids and compaction behavior still flow
through the real `run.ts` / `compact.ts` paths; helper-only tests are not a
sufficient substitute for those seams.
- Pool note:
- OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards.
- On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there.

View File

@ -119,19 +119,24 @@ src/agents/
│ ├── browser-tool.ts
│ ├── canvas-tool.ts
│ ├── cron-tool.ts
│ ├── discord-actions*.ts
│ ├── gateway-tool.ts
│ ├── image-tool.ts
│ ├── message-tool.ts
│ ├── nodes-tool.ts
│ ├── session*.ts
│ ├── slack-actions.ts
│ ├── telegram-actions.ts
│ ├── web-*.ts
│ └── whatsapp-actions.ts
│ └── ...
└── ...
```
Channel-specific message action runtimes now live in the plugin-owned extension
directories instead of under `src/agents/tools`, for example:
- `extensions/discord/src/actions/runtime*.ts`
- `extensions/slack/src/action-runtime.ts`
- `extensions/telegram/src/action-runtime.ts`
- `extensions/whatsapp/src/action-runtime.ts`
## Core Integration Flow
### 1. Running an Embedded Agent

View File

@ -188,6 +188,64 @@ The important design boundary:
That split lets OpenClaw validate config, explain missing/disabled plugins, and
build UI/schema hints before the full runtime is active.
### Channel plugins and the shared message tool
Channel plugins do not need to register a separate send/edit/react tool for
normal chat actions. OpenClaw keeps one shared `message` tool in core, and
channel plugins own the channel-specific discovery and execution behind it.
The current boundary is:
- core owns the shared `message` tool host, prompt wiring, session/thread
bookkeeping, and execution dispatch
- channel plugins own scoped action discovery, capability discovery, and any
channel-specific schema fragments
- channel plugins execute the final action through their action adapter
For channel plugins, the preferred SDK surface is
`ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery
call lets a plugin return its visible actions, capabilities, and schema
contributions together so those pieces do not drift apart.
Core passes runtime scope into that discovery step. Important fields include:
- `accountId`
- `currentChannelId`
- `currentThreadTs`
- `currentMessageId`
- `sessionKey`
- `sessionId`
- `agentId`
- trusted inbound `requesterSenderId`
That matters for context-sensitive plugins. A channel can hide or expose
message actions based on the active account, current room/thread/message, or
trusted requester identity without hardcoding channel-specific branches in the
core `message` tool.
This is why embedded-runner routing changes are still plugin work: the runner is
responsible for forwarding the current chat/session identity into the plugin
discovery boundary so the shared `message` tool exposes the right channel-owned
surface for the current turn.
For channel-owned execution helpers, bundled plugins should keep the execution
runtime inside their own extension modules. Core no longer owns the Discord,
Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`.
We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled
plugins should import their own local runtime code directly from their
extension-owned modules.
For polls specifically, there are two execution paths:
- `outbound.sendPoll` is the shared baseline for channels that fit the common
poll model
- `actions.handleAction("poll")` is the preferred path for channel-specific
poll semantics or extra poll parameters
Core now defers shared poll parsing until after plugin poll dispatch declines
the action, so plugin-owned poll handlers can accept channel-specific poll
fields without being blocked by the generic poll parser first.
## Capability ownership model
OpenClaw treats a native plugin as the ownership boundary for a **company** or a

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.3.17",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",

View File

@ -1,4 +1,7 @@
export * from "./src/audit.js";
export * from "./src/actions/runtime.js";
export * from "./src/actions/runtime.moderation-shared.js";
export * from "./src/actions/runtime.shared.js";
export * from "./src/channel-actions.js";
export * from "./src/directory-live.js";
export * from "./src/monitor.js";

View File

@ -5,12 +5,12 @@ import {
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
import { handleDiscordAction } from "./runtime.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
} from "openclaw/plugin-sdk/agent-runtime";
import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
} from "./runtime.moderation-shared.js";
type Ctx = Pick<
ChannelMessageActionContext,

View File

@ -4,8 +4,6 @@ import {
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
import { readDiscordParentIdParam } from "openclaw/plugin-sdk/agent-runtime";
import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
@ -13,6 +11,8 @@ import { normalizeInteractiveReply } from "openclaw/plugin-sdk/channel-runtime";
import { buildDiscordInteractiveComponents } from "../shared-interactive.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
import { handleDiscordAction } from "./runtime.js";
import { readDiscordParentIdParam } from "./runtime.shared.js";
const providerId = "discord";

View File

@ -1,5 +1,14 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
type ActionGate,
jsonResult,
parseAvailableTags,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "../../../../src/agents/tools/common.js";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import { getPresence } from "../monitor/presence-cache.js";
import {
addRoleDiscord,
createChannelDiscord,
@ -19,17 +28,29 @@ import {
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "../../plugin-sdk/discord.js";
import { getPresence } from "../../plugin-sdk/discord.js";
import {
type ActionGate,
jsonResult,
parseAvailableTags,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "./common.js";
import { readDiscordParentIdParam } from "./discord-actions-shared.js";
} from "../send.js";
import { readDiscordParentIdParam } from "./runtime.shared.js";
export const discordGuildActionRuntime = {
addRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listScheduledEventsDiscord,
moveChannelDiscord,
removeChannelPermissionDiscord,
removeRoleDiscord,
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
};
type DiscordRoleMutation = (params: {
guildId: string;
@ -85,8 +106,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const member = accountId
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
: await fetchMemberInfoDiscord(guildId, userId);
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId })
: await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId);
const presence = getPresence(accountId, userId);
const activities = presence?.activities ?? undefined;
const status = presence?.status ?? undefined;
@ -100,8 +121,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const roles = accountId
? await fetchRoleInfoDiscord(guildId, { accountId })
: await fetchRoleInfoDiscord(guildId);
? await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId, { accountId })
: await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId);
return jsonResult({ ok: true, roles });
}
case "emojiList": {
@ -112,8 +133,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const emojis = accountId
? await listGuildEmojisDiscord(guildId, { accountId })
: await listGuildEmojisDiscord(guildId);
? await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, { accountId })
: await discordGuildActionRuntime.listGuildEmojisDiscord(guildId);
return jsonResult({ ok: true, emojis });
}
case "emojiUpload": {
@ -129,7 +150,7 @@ export async function handleDiscordGuildAction(
});
const roleIds = readStringArrayParam(params, "roleIds");
const emoji = accountId
? await uploadEmojiDiscord(
? await discordGuildActionRuntime.uploadEmojiDiscord(
{
guildId,
name,
@ -138,7 +159,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await uploadEmojiDiscord({
: await discordGuildActionRuntime.uploadEmojiDiscord({
guildId,
name,
mediaUrl,
@ -162,7 +183,7 @@ export async function handleDiscordGuildAction(
required: true,
});
const sticker = accountId
? await uploadStickerDiscord(
? await discordGuildActionRuntime.uploadStickerDiscord(
{
guildId,
name,
@ -172,7 +193,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await uploadStickerDiscord({
: await discordGuildActionRuntime.uploadStickerDiscord({
guildId,
name,
description,
@ -185,14 +206,22 @@ export async function handleDiscordGuildAction(
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
await runRoleMutation({ accountId, values: params, mutate: addRoleDiscord });
await runRoleMutation({
accountId,
values: params,
mutate: discordGuildActionRuntime.addRoleDiscord,
});
return jsonResult({ ok: true });
}
case "roleRemove": {
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
await runRoleMutation({ accountId, values: params, mutate: removeRoleDiscord });
await runRoleMutation({
accountId,
values: params,
mutate: discordGuildActionRuntime.removeRoleDiscord,
});
return jsonResult({ ok: true });
}
case "channelInfo": {
@ -203,8 +232,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const channel = accountId
? await fetchChannelInfoDiscord(channelId, { accountId })
: await fetchChannelInfoDiscord(channelId);
? await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId, { accountId })
: await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId);
return jsonResult({ ok: true, channel });
}
case "channelList": {
@ -215,8 +244,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const channels = accountId
? await listGuildChannelsDiscord(guildId, { accountId })
: await listGuildChannelsDiscord(guildId);
? await discordGuildActionRuntime.listGuildChannelsDiscord(guildId, { accountId })
: await discordGuildActionRuntime.listGuildChannelsDiscord(guildId);
return jsonResult({ ok: true, channels });
}
case "voiceStatus": {
@ -230,8 +259,10 @@ export async function handleDiscordGuildAction(
required: true,
});
const voice = accountId
? await fetchVoiceStatusDiscord(guildId, userId, { accountId })
: await fetchVoiceStatusDiscord(guildId, userId);
? await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId, {
accountId,
})
: await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId);
return jsonResult({ ok: true, voice });
}
case "eventList": {
@ -242,8 +273,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const events = accountId
? await listScheduledEventsDiscord(guildId, { accountId })
: await listScheduledEventsDiscord(guildId);
? await discordGuildActionRuntime.listScheduledEventsDiscord(guildId, { accountId })
: await discordGuildActionRuntime.listScheduledEventsDiscord(guildId);
return jsonResult({ ok: true, events });
}
case "eventCreate": {
@ -274,8 +305,10 @@ export async function handleDiscordGuildAction(
privacy_level: 2,
};
const event = accountId
? await createScheduledEventDiscord(guildId, payload, { accountId })
: await createScheduledEventDiscord(guildId, payload);
? await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload, {
accountId,
})
: await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event });
}
case "channelCreate": {
@ -290,7 +323,7 @@ export async function handleDiscordGuildAction(
const position = readNumberParam(params, "position", { integer: true });
const nsfw = params.nsfw as boolean | undefined;
const channel = accountId
? await createChannelDiscord(
? await discordGuildActionRuntime.createChannelDiscord(
{
guildId,
name,
@ -302,7 +335,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await createChannelDiscord({
: await discordGuildActionRuntime.createChannelDiscord({
guildId,
name,
type: type ?? undefined,
@ -348,8 +381,8 @@ export async function handleDiscordGuildAction(
availableTags,
};
const channel = accountId
? await editChannelDiscord(editPayload, { accountId })
: await editChannelDiscord(editPayload);
? await discordGuildActionRuntime.editChannelDiscord(editPayload, { accountId })
: await discordGuildActionRuntime.editChannelDiscord(editPayload);
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
@ -360,8 +393,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const result = accountId
? await deleteChannelDiscord(channelId, { accountId })
: await deleteChannelDiscord(channelId);
? await discordGuildActionRuntime.deleteChannelDiscord(channelId, { accountId })
: await discordGuildActionRuntime.deleteChannelDiscord(channelId);
return jsonResult(result);
}
case "channelMove": {
@ -375,7 +408,7 @@ export async function handleDiscordGuildAction(
const parentId = readDiscordParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
if (accountId) {
await moveChannelDiscord(
await discordGuildActionRuntime.moveChannelDiscord(
{
guildId,
channelId,
@ -385,7 +418,7 @@ export async function handleDiscordGuildAction(
{ accountId },
);
} else {
await moveChannelDiscord({
await discordGuildActionRuntime.moveChannelDiscord({
guildId,
channelId,
parentId,
@ -402,7 +435,7 @@ export async function handleDiscordGuildAction(
const name = readStringParam(params, "name", { required: true });
const position = readNumberParam(params, "position", { integer: true });
const channel = accountId
? await createChannelDiscord(
? await discordGuildActionRuntime.createChannelDiscord(
{
guildId,
name,
@ -411,7 +444,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await createChannelDiscord({
: await discordGuildActionRuntime.createChannelDiscord({
guildId,
name,
type: 4,
@ -429,7 +462,7 @@ export async function handleDiscordGuildAction(
const name = readStringParam(params, "name");
const position = readNumberParam(params, "position", { integer: true });
const channel = accountId
? await editChannelDiscord(
? await discordGuildActionRuntime.editChannelDiscord(
{
channelId: categoryId,
name: name ?? undefined,
@ -437,7 +470,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await editChannelDiscord({
: await discordGuildActionRuntime.editChannelDiscord({
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
@ -452,8 +485,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const result = accountId
? await deleteChannelDiscord(categoryId, { accountId })
: await deleteChannelDiscord(categoryId);
? await discordGuildActionRuntime.deleteChannelDiscord(categoryId, { accountId })
: await discordGuildActionRuntime.deleteChannelDiscord(categoryId);
return jsonResult(result);
}
case "channelPermissionSet": {
@ -468,7 +501,7 @@ export async function handleDiscordGuildAction(
const allow = readStringParam(params, "allow");
const deny = readStringParam(params, "deny");
if (accountId) {
await setChannelPermissionDiscord(
await discordGuildActionRuntime.setChannelPermissionDiscord(
{
channelId,
targetId,
@ -479,7 +512,7 @@ export async function handleDiscordGuildAction(
{ accountId },
);
} else {
await setChannelPermissionDiscord({
await discordGuildActionRuntime.setChannelPermissionDiscord({
channelId,
targetId,
targetType,
@ -495,9 +528,11 @@ export async function handleDiscordGuildAction(
}
const { channelId, targetId } = readChannelPermissionTarget(params);
if (accountId) {
await removeChannelPermissionDiscord(channelId, targetId, { accountId });
await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId, {
accountId,
});
} else {
await removeChannelPermissionDiscord(channelId, targetId);
await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId);
}
return jsonResult({ ok: true });
}

View File

@ -1,7 +1,19 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { withNormalizedTimestamp } from "../../../../src/agents/date-time.js";
import { assertMediaNotDataUrl } from "../../../../src/agents/sandbox-paths.js";
import {
type ActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "../../../../src/agents/tools/common.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import { resolvePollMaxSelections } from "../../../../src/polls.js";
import { readDiscordComponentSpec } from "../components.js";
import {
createThreadDiscord,
deleteMessageDiscord,
@ -23,20 +35,34 @@ import {
sendStickerDiscord,
sendVoiceMessageDiscord,
unpinMessageDiscord,
} from "../../plugin-sdk/discord.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js";
import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
import {
type ActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "./common.js";
} from "../send.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js";
import { resolveDiscordChannelId } from "../targets.js";
export const discordMessagingActionRuntime = {
createThreadDiscord,
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchMessageDiscord,
fetchReactionsDiscord,
listPinsDiscord,
listThreadsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readDiscordComponentSpec,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
resolveDiscordChannelId,
searchMessagesDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendVoiceMessageDiscord,
unpinMessageDiscord,
};
function parseDiscordMessageLink(link: string) {
const normalized = link.trim();
@ -65,7 +91,7 @@ export async function handleDiscordMessagingAction(
cfg?: OpenClawConfig,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
resolveDiscordChannelId(
discordMessagingActionRuntime.resolveDiscordChannelId(
readStringParam(params, "channelId", {
required: true,
}),
@ -95,28 +121,45 @@ export async function handleDiscordMessagingAction(
});
if (remove) {
if (accountId) {
await removeReactionDiscord(channelId, messageId, emoji, {
await discordMessagingActionRuntime.removeReactionDiscord(channelId, messageId, emoji, {
...cfgOptions,
accountId,
});
} else {
await removeReactionDiscord(channelId, messageId, emoji, cfgOptions);
await discordMessagingActionRuntime.removeReactionDiscord(
channelId,
messageId,
emoji,
cfgOptions,
);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = accountId
? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId })
: await removeOwnReactionsDiscord(channelId, messageId, cfgOptions);
? await discordMessagingActionRuntime.removeOwnReactionsDiscord(channelId, messageId, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.removeOwnReactionsDiscord(
channelId,
messageId,
cfgOptions,
);
return jsonResult({ ok: true, removed: removed.removed });
}
if (accountId) {
await reactMessageDiscord(channelId, messageId, emoji, {
await discordMessagingActionRuntime.reactMessageDiscord(channelId, messageId, emoji, {
...cfgOptions,
accountId,
});
} else {
await reactMessageDiscord(channelId, messageId, emoji, cfgOptions);
await discordMessagingActionRuntime.reactMessageDiscord(
channelId,
messageId,
emoji,
cfgOptions,
);
}
return jsonResult({ ok: true, added: emoji });
}
@ -129,11 +172,15 @@ export async function handleDiscordMessagingAction(
required: true,
});
const limit = readNumberParam(params, "limit");
const reactions = await fetchReactionsDiscord(channelId, messageId, {
...cfgOptions,
...(accountId ? { accountId } : {}),
limit,
});
const reactions = await discordMessagingActionRuntime.fetchReactionsDiscord(
channelId,
messageId,
{
...cfgOptions,
...(accountId ? { accountId } : {}),
limit,
},
);
return jsonResult({ ok: true, reactions });
}
case "sticker": {
@ -146,7 +193,7 @@ export async function handleDiscordMessagingAction(
required: true,
label: "stickerIds",
});
await sendStickerDiscord(to, stickerIds, {
await discordMessagingActionRuntime.sendStickerDiscord(to, stickerIds, {
...cfgOptions,
...(accountId ? { accountId } : {}),
content,
@ -169,7 +216,7 @@ export async function handleDiscordMessagingAction(
const allowMultiselect = readBooleanParam(params, "allowMultiselect");
const durationHours = readNumberParam(params, "durationHours");
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
await sendPollDiscord(
await discordMessagingActionRuntime.sendPollDiscord(
to,
{ question, options: answers, maxSelections, durationHours },
{ ...cfgOptions, ...(accountId ? { accountId } : {}), content },
@ -182,8 +229,11 @@ export async function handleDiscordMessagingAction(
}
const channelId = resolveChannelId();
const permissions = accountId
? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId })
: await fetchChannelPermissionsDiscord(channelId, cfgOptions);
? await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, cfgOptions);
return jsonResult({ ok: true, permissions });
}
case "fetchMessage": {
@ -206,8 +256,11 @@ export async function handleDiscordMessagingAction(
);
}
const message = accountId
? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId })
: await fetchMessageDiscord(channelId, messageId, cfgOptions);
? await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, cfgOptions);
return jsonResult({
ok: true,
message: normalizeMessage(message),
@ -228,8 +281,11 @@ export async function handleDiscordMessagingAction(
around: readStringParam(params, "around"),
};
const messages = accountId
? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId })
: await readMessagesDiscord(channelId, query, cfgOptions);
? await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, cfgOptions);
return jsonResult({
ok: true,
messages: messages.map((message) => normalizeMessage(message)),
@ -245,7 +301,7 @@ export async function handleDiscordMessagingAction(
const rawComponents = params.components;
const componentSpec =
rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents)
? readDiscordComponentSpec(rawComponents)
? discordMessagingActionRuntime.readDiscordComponentSpec(rawComponents)
: null;
const components: DiscordSendComponents | undefined =
Array.isArray(rawComponents) || typeof rawComponents === "function"
@ -279,16 +335,20 @@ export async function handleDiscordMessagingAction(
const payload = componentSpec.text
? componentSpec
: { ...componentSpec, text: normalizedContent };
const result = await sendDiscordComponentMessage(to, payload, {
...cfgOptions,
...(accountId ? { accountId } : {}),
silent,
replyTo: replyTo ?? undefined,
sessionKey: sessionKey ?? undefined,
agentId: agentId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
});
const result = await discordMessagingActionRuntime.sendDiscordComponentMessage(
to,
payload,
{
...cfgOptions,
...(accountId ? { accountId } : {}),
silent,
replyTo: replyTo ?? undefined,
sessionKey: sessionKey ?? undefined,
agentId: agentId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
},
);
return jsonResult({ ok: true, result, components: true });
}
@ -305,7 +365,7 @@ export async function handleDiscordMessagingAction(
);
}
assertMediaNotDataUrl(mediaUrl);
const result = await sendVoiceMessageDiscord(to, mediaUrl, {
const result = await discordMessagingActionRuntime.sendVoiceMessageDiscord(to, mediaUrl, {
...cfgOptions,
...(accountId ? { accountId } : {}),
replyTo,
@ -314,7 +374,7 @@ export async function handleDiscordMessagingAction(
return jsonResult({ ok: true, result, voiceMessage: true });
}
const result = await sendMessageDiscord(to, content ?? "", {
const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", {
...cfgOptions,
...(accountId ? { accountId } : {}),
mediaUrl,
@ -338,8 +398,18 @@ export async function handleDiscordMessagingAction(
required: true,
});
const message = accountId
? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId })
: await editMessageDiscord(channelId, messageId, { content }, cfgOptions);
? await discordMessagingActionRuntime.editMessageDiscord(
channelId,
messageId,
{ content },
{ ...cfgOptions, accountId },
)
: await discordMessagingActionRuntime.editMessageDiscord(
channelId,
messageId,
{ content },
cfgOptions,
);
return jsonResult({ ok: true, message });
}
case "deleteMessage": {
@ -351,9 +421,12 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, {
...cfgOptions,
accountId,
});
} else {
await deleteMessageDiscord(channelId, messageId, cfgOptions);
await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@ -375,8 +448,11 @@ export async function handleDiscordMessagingAction(
appliedTags: appliedTags ?? undefined,
};
const thread = accountId
? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId })
: await createThreadDiscord(channelId, payload, cfgOptions);
? await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, cfgOptions);
return jsonResult({ ok: true, thread });
}
case "threadList": {
@ -391,7 +467,7 @@ export async function handleDiscordMessagingAction(
const before = readStringParam(params, "before");
const limit = readNumberParam(params, "limit");
const threads = accountId
? await listThreadsDiscord(
? await discordMessagingActionRuntime.listThreadsDiscord(
{
guildId,
channelId,
@ -401,7 +477,7 @@ export async function handleDiscordMessagingAction(
},
{ ...cfgOptions, accountId },
)
: await listThreadsDiscord(
: await discordMessagingActionRuntime.listThreadsDiscord(
{
guildId,
channelId,
@ -423,13 +499,17 @@ export async function handleDiscordMessagingAction(
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
...cfgOptions,
...(accountId ? { accountId } : {}),
mediaUrl,
mediaLocalRoots: options?.mediaLocalRoots,
replyTo,
});
const result = await discordMessagingActionRuntime.sendMessageDiscord(
`channel:${channelId}`,
content,
{
...cfgOptions,
...(accountId ? { accountId } : {}),
mediaUrl,
mediaLocalRoots: options?.mediaLocalRoots,
replyTo,
},
);
return jsonResult({ ok: true, result });
}
case "pinMessage": {
@ -441,9 +521,12 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, {
...cfgOptions,
accountId,
});
} else {
await pinMessageDiscord(channelId, messageId, cfgOptions);
await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@ -456,9 +539,12 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, {
...cfgOptions,
accountId,
});
} else {
await unpinMessageDiscord(channelId, messageId, cfgOptions);
await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@ -468,8 +554,11 @@ export async function handleDiscordMessagingAction(
}
const channelId = resolveChannelId();
const pins = accountId
? await listPinsDiscord(channelId, { ...cfgOptions, accountId })
: await listPinsDiscord(channelId, cfgOptions);
? await discordMessagingActionRuntime.listPinsDiscord(channelId, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.listPinsDiscord(channelId, cfgOptions);
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
case "searchMessages": {
@ -490,7 +579,7 @@ export async function handleDiscordMessagingAction(
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
const results = accountId
? await searchMessagesDiscord(
? await discordMessagingActionRuntime.searchMessagesDiscord(
{
guildId,
content,
@ -500,7 +589,7 @@ export async function handleDiscordMessagingAction(
},
{ ...cfgOptions, accountId },
)
: await searchMessagesDiscord(
: await discordMessagingActionRuntime.searchMessagesDiscord(
{
guildId,
content,

View File

@ -1,5 +1,5 @@
import { PermissionFlagsBits } from "discord-api-types/v10";
import { readNumberParam, readStringParam } from "./common.js";
import { readNumberParam, readStringParam } from "../../../../src/agents/tools/common.js";
export type DiscordModerationAction = "timeout" | "kick" | "ban";

View File

@ -1,25 +1,30 @@
import { PermissionFlagsBits } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../config/config.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import {
discordModerationActionRuntime,
handleDiscordModerationAction,
} from "./runtime.moderation.js";
const discordSendMocks = vi.hoisted(() => ({
banMemberDiscord: vi.fn(async () => ({ ok: true })),
kickMemberDiscord: vi.fn(async () => ({ ok: true })),
timeoutMemberDiscord: vi.fn(async () => ({ id: "user-1" })),
hasAnyGuildPermissionDiscord: vi.fn(async () => false),
}));
const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasAnyGuildPermissionDiscord } =
discordSendMocks;
vi.mock("../../../extensions/discord/src/send.js", () => ({
...discordSendMocks,
}));
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };
const banMemberDiscord = vi.fn(async () => ({ ok: true }));
const kickMemberDiscord = vi.fn(async () => ({ ok: true }));
const timeoutMemberDiscord = vi.fn(async () => ({ id: "user-1" }));
const hasAnyGuildPermissionDiscord = vi.fn(async () => false);
const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true;
describe("discord moderation sender authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(discordModerationActionRuntime, originalDiscordModerationActionRuntime, {
banMemberDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
hasAnyGuildPermissionDiscord,
});
});
it("rejects ban when sender lacks BAN_MEMBERS", async () => {
hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false);

View File

@ -1,17 +1,28 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
type ActionGate,
jsonResult,
readStringParam,
} from "../../../../src/agents/tools/common.js";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import {
banMemberDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
} from "../../plugin-sdk/discord.js";
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
} from "../send.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
requiredGuildPermissionForModerationAction,
} from "./discord-actions-moderation-shared.js";
} from "./runtime.moderation-shared.js";
export const discordModerationActionRuntime = {
banMemberDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
};
async function verifySenderModerationPermission(params: {
guildId: string;
@ -23,7 +34,7 @@ async function verifySenderModerationPermission(params: {
if (!params.senderUserId) {
return;
}
const hasPermission = await hasAnyGuildPermissionDiscord(
const hasPermission = await discordModerationActionRuntime.hasAnyGuildPermissionDiscord(
params.guildId,
params.senderUserId,
[params.requiredPermission],
@ -57,7 +68,7 @@ export async function handleDiscordModerationAction(
switch (command.action) {
case "timeout": {
const member = accountId
? await timeoutMemberDiscord(
? await discordModerationActionRuntime.timeoutMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
@ -67,7 +78,7 @@ export async function handleDiscordModerationAction(
},
{ accountId },
)
: await timeoutMemberDiscord({
: await discordModerationActionRuntime.timeoutMemberDiscord({
guildId: command.guildId,
userId: command.userId,
durationMinutes: command.durationMinutes,
@ -78,7 +89,7 @@ export async function handleDiscordModerationAction(
}
case "kick": {
if (accountId) {
await kickMemberDiscord(
await discordModerationActionRuntime.kickMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
@ -87,7 +98,7 @@ export async function handleDiscordModerationAction(
{ accountId },
);
} else {
await kickMemberDiscord({
await discordModerationActionRuntime.kickMemberDiscord({
guildId: command.guildId,
userId: command.userId,
reason: command.reason,
@ -97,7 +108,7 @@ export async function handleDiscordModerationAction(
}
case "ban": {
if (accountId) {
await banMemberDiscord(
await discordModerationActionRuntime.banMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
@ -107,7 +118,7 @@ export async function handleDiscordModerationAction(
{ accountId },
);
} else {
await banMemberDiscord({
await discordModerationActionRuntime.banMemberDiscord({
guildId: command.guildId,
userId: command.userId,
reason: command.reason,

View File

@ -1,12 +1,9 @@
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearGateways,
registerGateway,
} from "../../../extensions/discord/src/monitor/gateway-registry.js";
import type { DiscordActionConfig } from "../../config/config.js";
import type { ActionGate } from "./common.js";
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
import type { ActionGate } from "../../../../src/agents/tools/common.js";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import { clearGateways, registerGateway } from "../monitor/gateway-registry.js";
import { handleDiscordPresenceAction } from "./runtime.presence.js";
const mockUpdatePresence = vi.fn();

View File

@ -1,8 +1,12 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import { getGateway } from "../../plugin-sdk/discord.js";
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
import {
type ActionGate,
jsonResult,
readStringParam,
} from "../../../../src/agents/tools/common.js";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import { getGateway } from "../monitor/gateway-registry.js";
const ACTIVITY_TYPE_MAP: Record<string, number> = {
playing: 0,

View File

@ -1,4 +1,4 @@
import { readStringParam } from "./common.js";
import { readStringParam } from "../../../../src/agents/tools/common.js";
export function readDiscordParentIdParam(
params: Record<string, unknown>,

View File

@ -1,11 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig, OpenClawConfig } from "../../config/config.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { handleDiscordAction } from "./discord-actions.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js";
import { handleDiscordAction } from "./runtime.js";
import {
discordMessagingActionRuntime,
handleDiscordMessagingAction,
} from "./runtime.messaging.js";
import {
discordModerationActionRuntime,
handleDiscordModerationAction,
} from "./runtime.moderation.js";
const discordSendMocks = vi.hoisted(() => ({
const originalDiscordMessagingActionRuntime = { ...discordMessagingActionRuntime };
const originalDiscordGuildActionRuntime = { ...discordGuildActionRuntime };
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };
const discordSendMocks = {
banMemberDiscord: vi.fn(async () => ({})),
createChannelDiscord: vi.fn(async () => ({
id: "new-channel",
@ -42,7 +53,7 @@ const discordSendMocks = vi.hoisted(() => ({
setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
timeoutMemberDiscord: vi.fn(async () => ({})),
unpinMessageDiscord: vi.fn(async () => ({})),
}));
};
const {
createChannelDiscord,
@ -67,21 +78,28 @@ const {
timeoutMemberDiscord,
} = discordSendMocks;
vi.mock("../../../extensions/discord/src/send.js", () => ({
...discordSendMocks,
}));
const enableAllActions = () => true;
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation";
describe("handleDiscordMessagingAction", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
Object.assign(
discordMessagingActionRuntime,
originalDiscordMessagingActionRuntime,
discordSendMocks,
);
Object.assign(discordGuildActionRuntime, originalDiscordGuildActionRuntime, discordSendMocks);
Object.assign(
discordModerationActionRuntime,
originalDiscordModerationActionRuntime,
discordSendMocks,
);
});
describe("handleDiscordMessagingAction", () => {
it.each([
{
name: "without account",

View File

@ -1,11 +1,11 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { createDiscordActionGate } from "../../plugin-sdk/discord.js";
import { readStringParam } from "./common.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
import { readStringParam } from "../../../../src/agents/tools/common.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import { createDiscordActionGate } from "../accounts.js";
import { handleDiscordGuildAction } from "./runtime.guild.js";
import { handleDiscordMessagingAction } from "./runtime.messaging.js";
import { handleDiscordModerationAction } from "./runtime.moderation.js";
import { handleDiscordPresenceAction } from "./runtime.presence.js";
const messagingActions = new Set([
"react",

View File

@ -79,12 +79,6 @@ function formatDiscordIntents(intents?: {
const discordMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null,
listActions: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
getCapabilities: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [],
getToolSchema: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.getToolSchema?.(ctx) ?? null,
extractToolSend: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null,
handleAction: async (ctx) => {
@ -366,6 +360,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType,
buildCrossContextComponents: buildDiscordCrossContextComponents,

View File

@ -54,6 +54,10 @@ vi.mock("./channel.runtime.js", () => ({
import { feishuPlugin } from "./channel.js";
function getDescribedActions(cfg: OpenClawConfig): string[] {
return [...(feishuPlugin.actions?.describeMessageTool?.({ cfg })?.actions ?? [])];
}
describe("feishuPlugin.status.probeAccount", () => {
it("uses current account credentials for multi-account config", async () => {
const cfg = {
@ -112,7 +116,7 @@ describe("feishuPlugin actions", () => {
});
it("advertises the expanded Feishu action surface", () => {
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual([
expect(getDescribedActions(cfg)).toEqual([
"send",
"read",
"edit",
@ -142,7 +146,7 @@ describe("feishuPlugin actions", () => {
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([
expect(getDescribedActions(disabledCfg)).toEqual([
"send",
"read",
"edit",

View File

@ -2,7 +2,10 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-runtime";
import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import {
buildChannelConfigSchema,
@ -49,6 +52,56 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
"feishuChannelRuntime",
);
function describeFeishuMessageTool({
cfg,
}: Parameters<
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const enabled =
cfg.channels?.feishu?.enabled !== false &&
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined));
if (listEnabledFeishuAccounts(cfg).length === 0) {
return {
actions: [],
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
},
}
: null,
};
}
const actions = new Set<ChannelMessageActionName>([
"send",
"read",
"edit",
"thread-reply",
"pin",
"list-pins",
"unpin",
"member-info",
"channel-info",
"channel-list",
]);
if (areAnyFeishuReactionActionsEnabled(cfg)) {
actions.add("react");
actions.add("reactions");
}
return {
actions: Array.from(actions),
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
},
}
: null,
};
}
function setFeishuNamedAccountEnabled(
cfg: ClawdbotConfig,
accountId: string,
@ -396,53 +449,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
},
actions: {
describeMessageTool: ({
cfg,
}: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0]) => {
const enabled =
cfg.channels?.feishu?.enabled !== false &&
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined));
if (listEnabledFeishuAccounts(cfg).length === 0) {
return {
actions: [],
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
},
}
: null,
};
}
const actions = new Set<ChannelMessageActionName>([
"send",
"read",
"edit",
"thread-reply",
"pin",
"list-pins",
"unpin",
"member-info",
"channel-info",
"channel-list",
]);
if (areAnyFeishuReactionActionsEnabled(cfg)) {
actions.add("react");
actions.add("reactions");
}
return {
actions: Array.from(actions),
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
},
}
: null,
};
},
describeMessageTool: describeFeishuMessageTool,
handleAction: async (ctx) => {
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined });
if (

View File

@ -0,0 +1 @@
export * from "openclaw/plugin-sdk/mattermost";

View File

@ -17,6 +17,10 @@ import {
withMockedGlobalFetch,
} from "./mattermost/reactions.test-helpers.js";
function getDescribedActions(cfg: OpenClawConfig): string[] {
return [...(mattermostPlugin.actions?.describeMessageTool?.({ cfg })?.actions ?? [])];
}
describe("mattermostPlugin", () => {
beforeEach(() => {
sendMessageMattermostMock.mockReset();
@ -132,7 +136,7 @@ describe("mattermostPlugin", () => {
},
};
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
const actions = getDescribedActions(cfg);
expect(actions).toContain("react");
expect(actions).toContain("send");
expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true);
@ -148,7 +152,7 @@ describe("mattermostPlugin", () => {
},
};
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
const actions = getDescribedActions(cfg);
expect(actions).toEqual([]);
});
@ -164,7 +168,7 @@ describe("mattermostPlugin", () => {
},
};
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
const actions = getDescribedActions(cfg);
expect(actions).not.toContain("react");
expect(actions).toContain("send");
});
@ -187,7 +191,7 @@ describe("mattermostPlugin", () => {
},
};
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
const actions = getDescribedActions(cfg);
expect(actions).toContain("react");
});

View File

@ -5,19 +5,7 @@ import {
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime";
import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelPlugin,
} from "openclaw/plugin-sdk/mattermost";
import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { MattermostConfigSchema } from "./config-schema.js";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
@ -38,50 +26,65 @@ import { addMattermostReaction, removeMattermostReaction } from "./mattermost/re
import { sendMessageMattermost } from "./mattermost/send.js";
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelPlugin,
} from "./runtime-api.js";
import { getMattermostRuntime } from "./runtime.js";
import { mattermostSetupAdapter } from "./setup-core.js";
import { mattermostSetupWizard } from "./setup-surface.js";
function describeMattermostMessageTool({
cfg,
}: Parameters<
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const enabledAccounts = listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled)
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()));
const actions: ChannelMessageActionName[] = [];
if (enabledAccounts.length > 0) {
actions.push("send");
}
const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = enabledAccounts.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
});
if (hasReactionCapableAccount) {
actions.push("react");
}
return {
actions,
capabilities: enabledAccounts.length > 0 ? ["buttons"] : [],
schema:
enabledAccounts.length > 0
? {
properties: {
buttons: createMessageToolButtonsSchema(),
},
}
: null,
};
}
const mattermostMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({
cfg,
}: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0]) => {
const enabledAccounts = listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled)
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()));
const actions: ChannelMessageActionName[] = [];
// Send (buttons) is available whenever there's at least one enabled account
if (enabledAccounts.length > 0) {
actions.push("send");
}
// React requires per-account reactions config check
const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = enabledAccounts.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
});
if (hasReactionCapableAccount) {
actions.push("react");
}
return {
actions,
capabilities: enabledAccounts.length > 0 ? ["buttons"] : [],
schema:
enabledAccounts.length > 0
? {
properties: {
buttons: createMessageToolButtonsSchema(),
},
}
: null,
};
},
describeMessageTool: describeMattermostMessageTool,
supportsAction: ({ action }) => {
return action === "send" || action === "react";
},

View File

@ -1,12 +1,12 @@
import { z } from "zod";
import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js";
import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/mattermost";
import { z } from "zod";
import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js";
} from "./runtime-api.js";
import { buildSecretInputSchema } from "./secret-input.js";
const DmChannelRetrySchema = z

View File

@ -1,6 +1,6 @@
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
import type { ChannelGroupContext } from "openclaw/plugin-sdk/mattermost";
import { resolveMattermostAccount } from "./mattermost/accounts.js";
import type { ChannelGroupContext } from "./runtime-api.js";
export function resolveMattermostGroupRequireMention(
params: ChannelGroupContext & { requireMentionOverride?: boolean },

View File

@ -1,5 +1,5 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
import type {
MattermostAccountConfig,

View File

@ -1,8 +1,4 @@
import type {
ChannelDirectoryEntry,
OpenClawConfig,
RuntimeEnv,
} from "openclaw/plugin-sdk/mattermost";
import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,

View File

@ -1,10 +1,6 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import {
isTrustedProxyAddress,
resolveClientIp,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "../runtime-api.js";
import { getMattermostRuntime } from "../runtime.js";
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";

View File

@ -6,7 +6,7 @@ import {
resolveStoredModelOverride,
type ModelsProviderData,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
} from "../runtime-api.js";
import type { MattermostInteractiveButtonInput } from "./interactions.js";
const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker";

View File

@ -1,11 +1,11 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import type { OpenClawConfig } from "../runtime-api.js";
import {
evaluateSenderGroupAccessForPolicy,
isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple,
resolveControlCommandGate,
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/mattermost";
} from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostChannel } from "./client.js";

View File

@ -2,8 +2,8 @@ import {
formatInboundFromLabel as formatInboundFromLabelShared,
resolveThreadSessionKeys as resolveThreadSessionKeysShared,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk/mattermost";
} from "../runtime-api.js";
export { createDedupeCache, rawDataToString } from "../runtime-api.js";
export type ResponsePrefixContext = {
model?: string;

View File

@ -1,5 +1,5 @@
import type { ChannelAccountSnapshot, RuntimeEnv } from "openclaw/plugin-sdk/mattermost";
import WebSocket from "ws";
import type { ChannelAccountSnapshot, RuntimeEnv } from "../runtime-api.js";
import type { MattermostPost } from "./client.js";
import { rawDataToString } from "./monitor-helpers.js";

View File

@ -4,7 +4,7 @@ import type {
OpenClawConfig,
ReplyPayload,
RuntimeEnv,
} from "openclaw/plugin-sdk/mattermost";
} from "../runtime-api.js";
import {
buildAgentMediaPayload,
buildModelsProviderData,
@ -30,7 +30,7 @@ import {
warnMissingProviderGroupPolicyFallbackOnce,
listSkillCommandsForAgents,
type HistoryEntry,
} from "openclaw/plugin-sdk/mattermost";
} from "../runtime-api.js";
import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js";
import {

View File

@ -1,4 +1,4 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/mattermost";
import type { BaseProbeResult } from "../runtime-api.js";
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
export type MattermostProbe = BaseProbeResult & {

View File

@ -1,4 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveMattermostAccount } from "./accounts.js";
import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js";

View File

@ -1,5 +1,5 @@
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "openclaw/plugin-sdk/mattermost";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost";
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js";
import { getAgentScopedMediaLocalRoots } from "../runtime-api.js";
type MarkdownTableMode = Parameters<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[1];

View File

@ -0,0 +1 @@
export * from "../../runtime-api.js";

View File

@ -1,4 +1,4 @@
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "../runtime-api.js";
import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {

View File

@ -6,6 +6,7 @@
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
import {
buildModelsProviderData,
createReplyPrefixOptions,
@ -16,8 +17,7 @@ import {
type OpenClawConfig,
type ReplyPayload,
type RuntimeEnv,
} from "openclaw/plugin-sdk/mattermost";
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
} from "../runtime-api.js";
import { getMattermostRuntime } from "../runtime.js";
import {
createMattermostClient,

View File

@ -10,7 +10,7 @@
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/mattermost";
import type { OpenClawPluginApi } from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js";
import { createSlashCommandHttpHandler } from "./slash-http.js";
@ -86,8 +86,8 @@ export function activateSlashCommands(params: {
registeredCommands: MattermostRegisteredCommand[];
triggerMap?: Map<string, string>;
api: {
cfg: import("openclaw/plugin-sdk/mattermost").OpenClawConfig;
runtime: import("openclaw/plugin-sdk/mattermost").RuntimeEnv;
cfg: import("../runtime-api.js").OpenClawConfig;
runtime: import("../runtime-api.js").RuntimeEnv;
};
log?: (msg: string) => void;
}) {

View File

@ -1,4 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,

View File

@ -0,0 +1 @@
export * from "../runtime-api.js";

View File

@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
createPluginRuntimeStore<PluginRuntime>("Mattermost runtime not initialized");

View File

@ -3,7 +3,7 @@ import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/mattermost";
} from "./runtime-api.js";
export {
buildSecretInputSchema,

View File

@ -1,4 +1,6 @@
import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
import {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
@ -7,9 +9,7 @@ import {
migrateBaseNameToDefaultAccount,
normalizeAccountId,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
} from "./runtime-api.js";
const channel = "mattermost" as const;

View File

@ -1,13 +1,13 @@
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup";
import { listMattermostAccountIds } from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
import {
applySetupAccountConfigPatch,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup";
import { listMattermostAccountIds } from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
} from "./runtime-api.js";
import {
isMattermostConfigured,
mattermostSetupAdapter,

View File

@ -3,7 +3,7 @@ import type {
DmPolicy,
GroupPolicy,
SecretInput,
} from "openclaw/plugin-sdk/mattermost";
} from "./runtime-api.js";
export type MattermostReplyToMode = "off" | "first" | "all";
export type MattermostChatTypeKey = "direct" | "channel" | "group";

View File

@ -1,7 +1,10 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-runtime";
import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type {
ChannelMessageActionName,
@ -64,6 +67,27 @@ const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport(
"msTeamsChannelRuntime",
);
function describeMSTeamsMessageTool({
cfg,
}: Parameters<
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const enabled =
cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
return {
actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [],
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
},
}
: null,
};
}
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
@ -370,24 +394,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
},
},
actions: {
describeMessageTool: ({
cfg,
}: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0]) => {
const enabled =
cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
return {
actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [],
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
},
}
: null,
};
},
describeMessageTool: describeMSTeamsMessageTool,
handleAction: async (ctx) => {
// Handle send action with card parameter
if (ctx.action === "send" && ctx.params.card) {

View File

@ -1,7 +1,3 @@
import {
buildPassiveChannelStatusSummary,
buildTrafficStatusSummary,
} from "../../shared/channel-status-summary.js";
import {
buildChannelConfigSchema,
collectStatusIssuesFromLastError,
@ -10,7 +6,11 @@ import {
formatPairingApproveHint,
mapAllowFromEntries,
type ChannelPlugin,
} from "../api.js";
} from "openclaw/plugin-sdk/nostr";
import {
buildPassiveChannelStatusSummary,
buildTrafficStatusSummary,
} from "../../shared/channel-status-summary.js";
import type { NostrProfile } from "./config-schema.js";
import { NostrConfigSchema } from "./config-schema.js";
import type { MetricEvent, MetricsSnapshot } from "./metrics.js";

View File

@ -1,6 +1,6 @@
import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema";
import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr";
import { z } from "zod";
import { MarkdownConfigSchema, buildChannelConfigSchema } from "../api.js";
/**
* Validates https:// URLs only (no javascript:, data:, file:, etc.)

View File

@ -1,3 +1,4 @@
export * from "./src/action-runtime.js";
export * from "./src/directory-live.js";
export * from "./src/index.js";
export * from "./src/resolve-channels.js";

View File

@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { handleSlackAction } from "./slack-actions.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { handleSlackAction, slackActionRuntime } from "./action-runtime.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
const originalSlackActionRuntime = { ...slackActionRuntime };
const deleteSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const downloadSlackFile = vi.fn(async (..._args: unknown[]) => null);
const editSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
@ -14,31 +16,10 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({}));
const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]);
const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({}));
const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const recordSlackThreadParticipation = vi.fn();
const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" }));
const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
vi.mock("../../../extensions/slack/src/actions.js", () => ({
deleteSlackMessage: (...args: Parameters<typeof deleteSlackMessage>) =>
deleteSlackMessage(...args),
downloadSlackFile: (...args: Parameters<typeof downloadSlackFile>) => downloadSlackFile(...args),
editSlackMessage: (...args: Parameters<typeof editSlackMessage>) => editSlackMessage(...args),
getSlackMemberInfo: (...args: Parameters<typeof getSlackMemberInfo>) =>
getSlackMemberInfo(...args),
listSlackEmojis: (...args: Parameters<typeof listSlackEmojis>) => listSlackEmojis(...args),
listSlackPins: (...args: Parameters<typeof listSlackPins>) => listSlackPins(...args),
listSlackReactions: (...args: Parameters<typeof listSlackReactions>) =>
listSlackReactions(...args),
pinSlackMessage: (...args: Parameters<typeof pinSlackMessage>) => pinSlackMessage(...args),
reactSlackMessage: (...args: Parameters<typeof reactSlackMessage>) => reactSlackMessage(...args),
readSlackMessages: (...args: Parameters<typeof readSlackMessages>) => readSlackMessages(...args),
removeOwnSlackReactions: (...args: Parameters<typeof removeOwnSlackReactions>) =>
removeOwnSlackReactions(...args),
removeSlackReaction: (...args: Parameters<typeof removeSlackReaction>) =>
removeSlackReaction(...args),
sendSlackMessage: (...args: Parameters<typeof sendSlackMessage>) => sendSlackMessage(...args),
unpinSlackMessage: (...args: Parameters<typeof unpinSlackMessage>) => unpinSlackMessage(...args),
}));
describe("handleSlackAction", () => {
function slackConfig(overrides?: Record<string, unknown>): OpenClawConfig {
return {
@ -105,6 +86,24 @@ describe("handleSlackAction", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(slackActionRuntime, originalSlackActionRuntime, {
deleteSlackMessage,
downloadSlackFile,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
parseSlackBlocksInput,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
recordSlackThreadParticipation,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
});
});
it.each([
@ -261,6 +260,7 @@ describe("handleSlackAction", () => {
{
action: "sendMessage",
to: "channel:C123",
content: "",
blocks,
},
slackConfig(),
@ -275,7 +275,7 @@ describe("handleSlackAction", () => {
it.each([
{
name: "invalid blocks JSON",
blocks: "{bad-json",
blocks: "{not json",
expectedError: /blocks must be valid JSON/i,
},
{ name: "empty blocks arrays", blocks: "[]", expectedError: /at least one block/i },
@ -285,6 +285,7 @@ describe("handleSlackAction", () => {
{
action: "sendMessage",
to: "channel:C123",
content: "",
blocks,
},
slackConfig(),
@ -311,8 +312,9 @@ describe("handleSlackAction", () => {
{
action: "sendMessage",
to: "channel:C123",
blocks: [{ type: "divider" }],
mediaUrl: "https://example.com/image.png",
content: "hello",
mediaUrl: "https://example.com/file.png",
blocks: JSON.stringify([{ type: "divider" }]),
},
slackConfig(),
),
@ -322,13 +324,13 @@ describe("handleSlackAction", () => {
it.each([
{
name: "JSON blocks",
blocks: JSON.stringify([{ type: "section", text: { type: "mrkdwn", text: "Updated" } }]),
expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "Updated" } }],
blocks: JSON.stringify([{ type: "divider" }]),
expectedBlocks: [{ type: "divider" }],
},
{
name: "array blocks",
blocks: [{ type: "divider" }],
expectedBlocks: [{ type: "divider" }],
blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }],
expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }],
},
])("passes $name to editSlackMessage", async ({ blocks, expectedBlocks }) => {
await handleSlackAction(
@ -336,6 +338,7 @@ describe("handleSlackAction", () => {
action: "editMessage",
channelId: "C123",
messageId: "123.456",
content: "",
blocks,
},
slackConfig(),
@ -360,40 +363,32 @@ describe("handleSlackAction", () => {
});
it("auto-injects threadTs from context when replyToMode=all", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "Auto-threaded",
content: "Threaded reply",
},
cfg,
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Auto-threaded", {
mediaUrl: undefined,
threadTs: "1111111111.111111",
blocks: undefined,
});
expectLastSlackSend("Threaded reply", "1111111111.111111");
});
it("replyToMode=first threads first message then stops", async () => {
const { cfg, context, hasRepliedRef } = createReplyToFirstScenario();
const { cfg, context } = createReplyToFirstScenario();
// First message should be threaded
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "First" },
cfg,
context,
);
expectLastSlackSend("First", "1111111111.111111");
expect(hasRepliedRef.value).toBe(true);
expectLastSlackSend("First", "1111111111.111111");
await sendSecondMessageAndExpectNoThread({ cfg, context });
});
@ -405,73 +400,54 @@ describe("handleSlackAction", () => {
action: "sendMessage",
to: "channel:C123",
content: "Explicit",
threadTs: "2222222222.222222",
threadTs: "9999999999.999999",
},
cfg,
context,
);
expectLastSlackSend("Explicit", "2222222222.222222");
expect(hasRepliedRef.value).toBe(true);
expectLastSlackSend("Explicit", "9999999999.999999");
expect(hasRepliedRef.value).toBe(true);
await sendSecondMessageAndExpectNoThread({ cfg, context });
});
it("replyToMode=first without hasRepliedRef does not thread", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C123", content: "No ref" }, cfg, {
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first",
// no hasRepliedRef
});
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", {
mediaUrl: undefined,
threadTs: undefined,
blocks: undefined,
});
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "No ref" },
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first",
},
);
expectLastSlackSend("No ref");
});
it("does not auto-inject threadTs when replyToMode=off", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "Off mode",
},
cfg,
{ action: "sendMessage", to: "channel:C123", content: "No thread" },
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "off",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Off mode", {
mediaUrl: undefined,
threadTs: undefined,
blocks: undefined,
});
expectLastSlackSend("No thread");
});
it("does not auto-inject threadTs when sending to different channel", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C999",
content: "Different channel",
},
cfg,
{ action: "sendMessage", to: "channel:C999", content: "Other channel" },
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Different channel", {
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", {
mediaUrl: undefined,
threadTs: undefined,
blocks: undefined,
@ -479,46 +455,34 @@ describe("handleSlackAction", () => {
});
it("explicit threadTs overrides context threadTs", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "Explicit thread",
threadTs: "2222222222.222222",
content: "Explicit wins",
threadTs: "9999999999.999999",
},
cfg,
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Explicit thread", {
mediaUrl: undefined,
threadTs: "2222222222.222222",
blocks: undefined,
});
expectLastSlackSend("Explicit wins", "9999999999.999999");
});
it("handles channel target without prefix when replyToMode=all", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
action: "sendMessage",
to: "C123",
content: "No prefix",
},
cfg,
{ action: "sendMessage", to: "C123", content: "Bare target" },
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("C123", "No prefix", {
expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", {
mediaUrl: undefined,
threadTs: "1111111111.111111",
blocks: undefined,
@ -526,104 +490,164 @@ describe("handleSlackAction", () => {
});
it("adds normalized timestamps to readMessages payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
readSlackMessages.mockResolvedValueOnce({
messages: [{ ts: "1735689600.456", text: "hi" }],
messages: [{ ts: "1712345678.123456", text: "hi" }],
hasMore: false,
});
const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const payload = result.details as {
messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const result = await handleSlackAction(
{ action: "readMessages", channelId: "C1" },
slackConfig(),
);
const expectedMs = Math.round(1735689600.456 * 1000);
expect(payload.messages[0].timestampMs).toBe(expectedMs);
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
expect(result).toMatchObject({
details: {
ok: true,
hasMore: false,
messages: [
expect.objectContaining({
ts: "1712345678.123456",
timestampMs: 1712345678123,
}),
],
},
});
});
it("passes threadId through to readSlackMessages", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction(
{ action: "readMessages", channelId: "C1", threadId: "12345.6789" },
cfg,
{ action: "readMessages", channelId: "C1", threadId: "1712345678.123456" },
slackConfig(),
);
const opts = readSlackMessages.mock.calls[0]?.[1] as { threadId?: string } | undefined;
expect(opts?.threadId).toBe("12345.6789");
expect(readSlackMessages).toHaveBeenCalledWith("C1", {
threadId: "1712345678.123456",
limit: undefined,
before: undefined,
after: undefined,
});
});
it("adds normalized timestamps to pin payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
listSlackPins.mockResolvedValueOnce([
{
type: "message",
message: { ts: "1735689600.789", text: "pinned" },
listSlackPins.mockResolvedValueOnce([{ message: { ts: "1712345678.123456", text: "pin" } }]);
const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, slackConfig());
expect(result).toMatchObject({
details: {
ok: true,
pins: [
{
message: expect.objectContaining({
ts: "1712345678.123456",
timestampMs: 1712345678123,
}),
},
],
},
]);
const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
const payload = result.details as {
pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>;
};
const expectedMs = Math.round(1735689600.789 * 1000);
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
});
it("uses user token for reads when available", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as OpenClawConfig;
expect(await resolveReadToken(cfg)).toBe("xoxp-1");
const token = await resolveReadToken(
slackConfig({
accounts: {
default: {
botToken: "xoxb-bot",
userToken: "xoxp-user",
},
},
}),
);
expect(token).toBe("xoxp-user");
});
it("falls back to bot token for reads when user token missing", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1" } },
} as OpenClawConfig;
expect(await resolveReadToken(cfg)).toBeUndefined();
const token = await resolveReadToken(
slackConfig({
accounts: {
default: {
botToken: "xoxb-bot",
},
},
}),
);
expect(token).toBeUndefined();
});
it("uses bot token for writes when userTokenReadOnly is true", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as OpenClawConfig;
expect(await resolveSendToken(cfg)).toBeUndefined();
const token = await resolveSendToken(
slackConfig({
accounts: {
default: {
botToken: "xoxb-bot",
userToken: "xoxp-user",
userTokenReadOnly: true,
},
},
}),
);
expect(token).toBeUndefined();
});
it("allows user token writes when bot token is missing", async () => {
const cfg = {
const token = await resolveSendToken({
channels: {
slack: { userToken: "xoxp-1", userTokenReadOnly: false },
slack: {
accounts: {
default: {
userToken: "xoxp-user",
userTokenReadOnly: false,
},
},
},
},
} as OpenClawConfig;
expect(await resolveSendToken(cfg)).toBe("xoxp-1");
} as OpenClawConfig);
expect(token).toBe("xoxp-user");
});
it("returns all emojis when no limit is provided", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
const emojiMap = { wave: "url1", smile: "url2", heart: "url3" };
listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: emojiMap });
const result = await handleSlackAction({ action: "emojiList" }, cfg);
const payload = result.details as { ok: boolean; emojis: { emoji: Record<string, string> } };
expect(payload.ok).toBe(true);
expect(Object.keys(payload.emojis.emoji)).toHaveLength(3);
listSlackEmojis.mockResolvedValueOnce({
ok: true,
emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" },
});
const result = await handleSlackAction({ action: "emojiList" }, slackConfig());
expect(result).toMatchObject({
details: {
ok: true,
emojis: {
emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" },
},
},
});
});
it("applies limit to emoji-list results", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
const emojiMap = { wave: "url1", smile: "url2", heart: "url3", fire: "url4", star: "url5" };
listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: emojiMap });
const result = await handleSlackAction({ action: "emojiList", limit: 2 }, cfg);
const payload = result.details as { ok: boolean; emojis: { emoji: Record<string, string> } };
expect(payload.ok).toBe(true);
const emojiKeys = Object.keys(payload.emojis.emoji);
expect(emojiKeys).toHaveLength(2);
expect(emojiKeys.every((k) => k in emojiMap)).toBe(true);
listSlackEmojis.mockResolvedValueOnce({
ok: true,
emoji: {
wave: "https://example.com/wave.png",
party: "https://example.com/party.png",
tada: "https://example.com/tada.png",
},
});
const result = await handleSlackAction({ action: "emojiList", limit: 2 }, slackConfig());
expect(result).toMatchObject({
details: {
ok: true,
emojis: {
emoji: {
party: "https://example.com/party.png",
tada: "https://example.com/tada.png",
},
},
},
});
});
});

View File

@ -1,6 +1,15 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveSlackAccount } from "../../plugin-sdk/account-resolution.js";
import { withNormalizedTimestamp } from "../../../src/agents/date-time.js";
import {
createActionGate,
imageResultFromFile,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "../../../src/agents/tools/common.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveSlackAccount } from "./accounts.js";
import {
deleteSlackMessage,
downloadSlackFile,
@ -16,22 +25,10 @@ import {
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
} from "../../plugin-sdk/slack.js";
import {
parseSlackBlocksInput,
parseSlackTarget,
recordSlackThreadParticipation,
resolveSlackChannelId,
} from "../../plugin-sdk/slack.js";
import { withNormalizedTimestamp } from "../date-time.js";
import {
createActionGate,
imageResultFromFile,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "./common.js";
} from "./actions.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
const messagingActions = new Set([
"sendMessage",
@ -44,6 +41,25 @@ const messagingActions = new Set([
const reactionsActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
export const slackActionRuntime = {
deleteSlackMessage,
downloadSlackFile,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
parseSlackBlocksInput,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
recordSlackThreadParticipation,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
};
export type SlackActionContext = {
/** Current channel ID for auto-threading. */
currentChannelId?: string;
@ -102,7 +118,7 @@ function resolveThreadTsFromContext(
}
function readSlackBlocksParam(params: Record<string, unknown>) {
return parseSlackBlocksInput(params.blocks);
return slackActionRuntime.parseSlackBlocksInput(params.blocks);
}
export async function handleSlackAction(
@ -163,28 +179,28 @@ export async function handleSlackAction(
});
if (remove) {
if (writeOpts) {
await removeSlackReaction(channelId, messageId, emoji, writeOpts);
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts);
} else {
await removeSlackReaction(channelId, messageId, emoji);
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = writeOpts
? await removeOwnSlackReactions(channelId, messageId, writeOpts)
: await removeOwnSlackReactions(channelId, messageId);
? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts)
: await slackActionRuntime.removeOwnSlackReactions(channelId, messageId);
return jsonResult({ ok: true, removed });
}
if (writeOpts) {
await reactSlackMessage(channelId, messageId, emoji, writeOpts);
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts);
} else {
await reactSlackMessage(channelId, messageId, emoji);
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji);
}
return jsonResult({ ok: true, added: emoji });
}
const reactions = readOpts
? await listSlackReactions(channelId, messageId, readOpts)
: await listSlackReactions(channelId, messageId);
? await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts)
: await slackActionRuntime.listSlackReactions(channelId, messageId);
return jsonResult({ ok: true, reactions });
}
@ -211,7 +227,7 @@ export async function handleSlackAction(
to,
context,
);
const result = await sendSlackMessage(to, content ?? "", {
const result = await slackActionRuntime.sendSlackMessage(to, content ?? "", {
...writeOpts,
mediaUrl: mediaUrl ?? undefined,
mediaLocalRoots: context?.mediaLocalRoots,
@ -220,7 +236,11 @@ export async function handleSlackAction(
});
if (threadTs && result.channelId && account.accountId) {
recordSlackThreadParticipation(account.accountId, result.channelId, threadTs);
slackActionRuntime.recordSlackThreadParticipation(
account.accountId,
result.channelId,
threadTs,
);
}
// Keep "first" mode consistent even when the agent explicitly provided
@ -248,12 +268,12 @@ export async function handleSlackAction(
throw new Error("Slack editMessage requires content or blocks.");
}
if (writeOpts) {
await editSlackMessage(channelId, messageId, content ?? "", {
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
...writeOpts,
blocks,
});
} else {
await editSlackMessage(channelId, messageId, content ?? "", {
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
blocks,
});
}
@ -265,9 +285,9 @@ export async function handleSlackAction(
required: true,
});
if (writeOpts) {
await deleteSlackMessage(channelId, messageId, writeOpts);
await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts);
} else {
await deleteSlackMessage(channelId, messageId);
await slackActionRuntime.deleteSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
@ -279,7 +299,7 @@ export async function handleSlackAction(
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await readSlackMessages(channelId, {
const result = await slackActionRuntime.readSlackMessages(channelId, {
...readOpts,
limit,
before: before ?? undefined,
@ -302,7 +322,7 @@ export async function handleSlackAction(
const maxBytes = account.config?.mediaMaxMb
? account.config.mediaMaxMb * 1024 * 1024
: 20 * 1024 * 1024;
const downloaded = await downloadSlackFile(fileId, {
const downloaded = await slackActionRuntime.downloadSlackFile(fileId, {
...readOpts,
maxBytes,
channelId,
@ -336,9 +356,9 @@ export async function handleSlackAction(
required: true,
});
if (writeOpts) {
await pinSlackMessage(channelId, messageId, writeOpts);
await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts);
} else {
await pinSlackMessage(channelId, messageId);
await slackActionRuntime.pinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
@ -347,15 +367,15 @@ export async function handleSlackAction(
required: true,
});
if (writeOpts) {
await unpinSlackMessage(channelId, messageId, writeOpts);
await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts);
} else {
await unpinSlackMessage(channelId, messageId);
await slackActionRuntime.unpinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
const pins = writeOpts
? await listSlackPins(channelId, readOpts)
: await listSlackPins(channelId);
? await slackActionRuntime.listSlackPins(channelId, readOpts)
: await slackActionRuntime.listSlackPins(channelId);
const normalizedPins = pins.map((pin) => {
const message = pin.message
? withNormalizedTimestamp(
@ -374,8 +394,8 @@ export async function handleSlackAction(
}
const userId = readStringParam(params, "userId", { required: true });
const info = writeOpts
? await getSlackMemberInfo(userId, readOpts)
: await getSlackMemberInfo(userId);
? await slackActionRuntime.getSlackMemberInfo(userId, readOpts)
: await slackActionRuntime.getSlackMemberInfo(userId);
return jsonResult({ ok: true, info });
}
@ -383,7 +403,9 @@ export async function handleSlackAction(
if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled.");
}
const result = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis();
const result = readOpts
? await slackActionRuntime.listSlackEmojis(readOpts)
: await slackActionRuntime.listSlackEmojis();
const limit = readNumberParam(params, "limit", { integer: true });
if (limit != null && limit > 0 && result.emoji != null) {
const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b));

View File

@ -417,6 +417,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
resolveSessionTarget: ({ id }) => normalizeSlackMessagingTarget(`channel:${id}`),
parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType,
resolveOutboundSessionRoute: async (params) => await resolveSlackOutboundSessionRoute(params),

View File

@ -1,4 +1,5 @@
export * from "./src/audit.js";
export * from "./src/action-runtime.js";
export * from "./src/channel-actions.js";
export * from "./src/monitor.js";
export * from "./src/probe.js";

View File

@ -1,8 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { captureEnv } from "../../test-utils/env.js";
import { handleTelegramAction, readTelegramButtons } from "./telegram-actions.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { captureEnv } from "../../../test/helpers/extensions/env.js";
import {
handleTelegramAction,
readTelegramButtons,
telegramActionRuntime,
} from "./action-runtime.js";
const originalTelegramActionRuntime = { ...telegramActionRuntime };
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
const sendMessageTelegram = vi.fn(async () => ({
messageId: "789",
@ -36,24 +41,6 @@ const createForumTopicTelegram = vi.fn(async () => ({
}));
let envSnapshot: ReturnType<typeof captureEnv>;
vi.mock("../../../extensions/telegram/src/send.js", () => ({
reactMessageTelegram: (...args: Parameters<typeof reactMessageTelegram>) =>
reactMessageTelegram(...args),
sendMessageTelegram: (...args: Parameters<typeof sendMessageTelegram>) =>
sendMessageTelegram(...args),
sendPollTelegram: (...args: Parameters<typeof sendPollTelegram>) => sendPollTelegram(...args),
sendStickerTelegram: (...args: Parameters<typeof sendStickerTelegram>) =>
sendStickerTelegram(...args),
deleteMessageTelegram: (...args: Parameters<typeof deleteMessageTelegram>) =>
deleteMessageTelegram(...args),
editMessageTelegram: (...args: Parameters<typeof editMessageTelegram>) =>
editMessageTelegram(...args),
editForumTopicTelegram: (...args: Parameters<typeof editForumTopicTelegram>) =>
editForumTopicTelegram(...args),
createForumTopicTelegram: (...args: Parameters<typeof createForumTopicTelegram>) =>
createForumTopicTelegram(...args),
}));
describe("handleTelegramAction", () => {
const defaultReactionAction = {
action: "react",
@ -107,6 +94,16 @@ describe("handleTelegramAction", () => {
beforeEach(() => {
envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]);
Object.assign(telegramActionRuntime, originalTelegramActionRuntime, {
reactMessageTelegram,
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
deleteMessageTelegram,
editMessageTelegram,
editForumTopicTelegram,
createForumTopicTelegram,
});
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
sendPollTelegram.mockClear();

View File

@ -1,15 +1,23 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import {
createTelegramActionGate,
resolveTelegramPollActionGateState,
} from "../../plugin-sdk/telegram.js";
import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js";
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
} from "../../../src/agents/tools/common.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { TelegramActionConfig } from "../../../src/config/types.telegram.js";
import { resolvePollMaxSelections } from "../../../src/polls.js";
import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js";
import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramTargetChatType,
} from "../../plugin-sdk/telegram.js";
} from "./inline-buttons.js";
import { resolveTelegramReactionLevel } from "./reaction-level.js";
import {
createForumTopicTelegram,
deleteMessageTelegram,
@ -19,22 +27,22 @@ import {
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
} from "../../plugin-sdk/telegram.js";
import {
} from "./send.js";
import { getCacheStats, searchStickers } from "./sticker-cache.js";
import { resolveTelegramToken } from "./token.js";
export const telegramActionRuntime = {
createForumTopicTelegram,
deleteMessageTelegram,
editForumTopicTelegram,
editMessageTelegram,
getCacheStats,
resolveTelegramReactionLevel,
resolveTelegramToken,
reactMessageTelegram,
searchStickers,
} from "../../plugin-sdk/telegram.js";
import { resolvePollMaxSelections } from "../../polls.js";
import {
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
} from "./common.js";
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
};
const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"];
@ -155,14 +163,19 @@ export async function handleTelegramAction(
hint: "Telegram bot token missing. Do not retry.",
});
}
let reactionResult: Awaited<ReturnType<typeof reactMessageTelegram>>;
let reactionResult: Awaited<ReturnType<typeof telegramActionRuntime.reactMessageTelegram>>;
try {
reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", {
cfg,
token,
remove,
accountId: accountId ?? undefined,
});
reactionResult = await telegramActionRuntime.reactMessageTelegram(
chatId ?? "",
messageId ?? 0,
emoji ?? "",
{
cfg,
token,
remove,
accountId: accountId ?? undefined,
},
);
} catch (err) {
const isInvalid = String(err).includes("REACTION_INVALID");
return jsonResult({
@ -241,7 +254,7 @@ export async function handleTelegramAction(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await sendMessageTelegram(to, content, {
const result = await telegramActionRuntime.sendMessageTelegram(to, content, {
cfg,
token,
accountId: accountId ?? undefined,
@ -290,7 +303,7 @@ export async function handleTelegramAction(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await sendPollTelegram(
const result = await telegramActionRuntime.sendPollTelegram(
to,
{
question,
@ -334,7 +347,7 @@ export async function handleTelegramAction(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
await deleteMessageTelegram(chatId ?? "", messageId ?? 0, {
await telegramActionRuntime.deleteMessageTelegram(chatId ?? "", messageId ?? 0, {
cfg,
token,
accountId: accountId ?? undefined,
@ -375,12 +388,17 @@ export async function handleTelegramAction(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, {
cfg,
token,
accountId: accountId ?? undefined,
buttons,
});
const result = await telegramActionRuntime.editMessageTelegram(
chatId ?? "",
messageId ?? 0,
content,
{
cfg,
token,
accountId: accountId ?? undefined,
buttons,
},
);
return jsonResult({
ok: true,
messageId: result.messageId,
@ -408,7 +426,7 @@ export async function handleTelegramAction(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await sendStickerTelegram(to, fileId, {
const result = await telegramActionRuntime.sendStickerTelegram(to, fileId, {
cfg,
token,
accountId: accountId ?? undefined,
@ -430,7 +448,7 @@ export async function handleTelegramAction(
}
const query = readStringParam(params, "query", { required: true });
const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
const results = searchStickers(query, limit);
const results = telegramActionRuntime.searchStickers(query, limit);
return jsonResult({
ok: true,
count: results.length,
@ -444,7 +462,7 @@ export async function handleTelegramAction(
}
if (action === "stickerCacheStats") {
const stats = getCacheStats();
const stats = telegramActionRuntime.getCacheStats();
return jsonResult({ ok: true, ...stats });
}
@ -464,7 +482,7 @@ export async function handleTelegramAction(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await createForumTopicTelegram(chatId ?? "", name, {
const result = await telegramActionRuntime.createForumTopicTelegram(chatId ?? "", name, {
cfg,
token,
accountId: accountId ?? undefined,
@ -500,13 +518,17 @@ export async function handleTelegramAction(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await editForumTopicTelegram(chatId ?? "", messageThreadId, {
cfg,
token,
accountId: accountId ?? undefined,
name: name ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
});
const result = await telegramActionRuntime.editForumTopicTelegram(
chatId ?? "",
messageThreadId,
{
cfg,
token,
accountId: accountId ?? undefined,
name: name ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
},
);
return jsonResult(result);
}

View File

@ -4,7 +4,6 @@ import {
readStringOrNumberParam,
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
import { handleTelegramAction } from "openclaw/plugin-sdk/agent-runtime";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime";
import {
@ -27,6 +26,7 @@ import {
listEnabledTelegramAccounts,
resolveTelegramPollActionGateState,
} from "./accounts.js";
import { handleTelegramAction } from "./action-runtime.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js";

View File

@ -250,12 +250,6 @@ function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
const telegramMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: (ctx) =>
getTelegramRuntime().channel.telegram.messageActions?.describeMessageTool?.(ctx) ?? null,
listActions: (ctx) =>
getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [],
getCapabilities: (ctx) =>
getTelegramRuntime().channel.telegram.messageActions?.getCapabilities?.(ctx) ?? [],
getToolSchema: (ctx) =>
getTelegramRuntime().channel.telegram.messageActions?.getToolSchema?.(ctx) ?? null,
extractToolSend: (ctx) =>
getTelegramRuntime().channel.telegram.messageActions?.extractToolSend?.(ctx) ?? null,
handleAction: async (ctx) => {

View File

@ -1,2 +1,3 @@
export * from "openclaw/plugin-sdk/tlon";
export * from "./src/setup-core.js";
export * from "./src/setup-surface.js";

View File

@ -1,10 +1,12 @@
import crypto from "node:crypto";
import { configureClient } from "@tloncorp/api";
import type {
ChannelAccountSnapshot,
ChannelOutboundAdapter,
ChannelPlugin,
OpenClawConfig,
} from "openclaw/plugin-sdk/tlon";
} from "../api.js";
import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../api.js";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonSetupWizard } from "./setup-surface.js";
import {
@ -230,7 +232,7 @@ export async function startTlonGatewayAccount(
accountId: account.accountId,
ship: account.ship,
url: account.url,
} as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot);
} as ChannelAccountSnapshot);
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
return monitorTlonProvider({
runtime: ctx.runtime,

View File

@ -1,5 +1,5 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon";
import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "../api.js";
import { tlonChannelConfigSchema } from "./config-schema.js";
import {
applyTlonSetupConfig,
@ -214,7 +214,7 @@ export const tlonPlugin: ChannelPlugin = {
lastError: runtime?.lastError ?? null,
probe,
};
return snapshot as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot;
return snapshot as ChannelAccountSnapshot;
},
},
gateway: {

View File

@ -1,5 +1,5 @@
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/tlon";
import { z } from "zod";
import { buildChannelConfigSchema } from "../api.js";
const ShipSchema = z.string().min(1);
const ChannelNestSchema = z.string().min(1);

View File

@ -1,4 +1,4 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/tlon";
import type { RuntimeEnv } from "../../api.js";
import type { Foreigns } from "../urbit/foreigns.js";
import { formatChangesDate } from "./utils.js";

View File

@ -1,4 +1,4 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/tlon";
import type { RuntimeEnv } from "../../api.js";
import { extractMessageText } from "./utils.js";
/**

View File

@ -1,5 +1,5 @@
import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk/tlon";
import { createLoggerBackedRuntime, createReplyPrefixOptions } from "openclaw/plugin-sdk/tlon";
import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "../../api.js";
import { createLoggerBackedRuntime, createReplyPrefixOptions } from "../../api.js";
import { getTlonRuntime } from "../runtime.js";
import { createSettingsManager, type TlonSettingsStore } from "../settings.js";
import { normalizeShip, parseChannelNest } from "../targets.js";

View File

@ -5,7 +5,7 @@ import { homedir } from "node:os";
import * as path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon";
import { fetchWithSsrFGuard } from "../../api.js";
import { getDefaultSsrFPolicy } from "../urbit/context.js";
// Default to OpenClaw workspace media directory

View File

@ -1,4 +1,4 @@
import { createDedupeCache } from "openclaw/plugin-sdk/tlon";
import { createDedupeCache } from "../../api.js";
export type ProcessedMessageTracker = {
mark: (id?: string | null) => boolean;

View File

@ -1,5 +1,5 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
import type { PluginRuntime } from "../api.js";
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
createPluginRuntimeStore<PluginRuntime>("Tlon runtime not initialized");

View File

@ -1,4 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon";
import type { OpenClawConfig } from "../api.js";
export type TlonResolvedAccount = {
accountId: string;

View File

@ -1,4 +1,4 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon";
import type { LookupFn, SsrFPolicy } from "../../api.js";
import { UrbitAuthError } from "./errors.js";
import { urbitFetch } from "./fetch.js";

View File

@ -1,4 +1,4 @@
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/tlon";
import { isBlockedHostnameOrIp } from "../../api.js";
export type UrbitBaseUrlValidation =
| { ok: true; baseUrl: string; hostname: string }

View File

@ -1,4 +1,4 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon";
import type { LookupFn, SsrFPolicy } from "../../api.js";
import { UrbitHttpError } from "./errors.js";
import { urbitFetch } from "./fetch.js";

View File

@ -1,4 +1,4 @@
import type { SsrFPolicy } from "openclaw/plugin-sdk/tlon";
import type { SsrFPolicy } from "../../api.js";
import { validateUrbitBaseUrl } from "./base-url.js";
import { UrbitUrlError } from "./errors.js";

View File

@ -1,5 +1,5 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon";
import type { LookupFn, SsrFPolicy } from "../../api.js";
import { fetchWithSsrFGuard } from "../../api.js";
import { validateUrbitBaseUrl } from "./base-url.js";
import { UrbitUrlError } from "./errors.js";

View File

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/tlon";
import type { LookupFn, SsrFPolicy } from "../../api.js";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
import { urbitFetch } from "./fetch.js";

View File

@ -2,7 +2,7 @@
* Upload an image from a URL to Tlon storage.
*/
import { uploadFile } from "@tloncorp/api";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/tlon";
import { fetchWithSsrFGuard } from "../../api.js";
import { getDefaultSsrFPolicy } from "./context.js";
/**

View File

@ -0,0 +1 @@
export { handleWhatsAppAction } from "./src/action-runtime.js";

View File

@ -1,4 +1,5 @@
export * from "./src/active-listener.js";
export * from "./src/action-runtime.js";
export * from "./src/agent-tools-login.js";
export * from "./src/auth-store.js";
export * from "./src/auto-reply.js";

View File

@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js";
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
import { ToolAuthorizationError } from "./common.js";
import { ToolAuthorizationError } from "../../../src/agents/tools/common.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js";
import { resolveWhatsAppAccount } from "./accounts.js";
export function resolveAuthorizedWhatsAppOutboundTarget(params: {
cfg: OpenClawConfig;

View File

@ -1,17 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { handleWhatsAppAction } from "./whatsapp-actions.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { handleWhatsAppAction, whatsAppActionRuntime } from "./action-runtime.js";
const { sendReactionWhatsApp, sendPollWhatsApp } = vi.hoisted(() => ({
sendReactionWhatsApp: vi.fn(async () => undefined),
sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })),
}));
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
sendReactionWhatsApp,
sendPollWhatsApp,
}));
const originalWhatsAppActionRuntime = { ...whatsAppActionRuntime };
const sendReactionWhatsApp = vi.fn(async () => undefined);
const enabledConfig = {
channels: { whatsapp: { actions: { reactions: true } } },
@ -20,6 +13,9 @@ const enabledConfig = {
describe("handleWhatsAppAction", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(whatsAppActionRuntime, originalWhatsAppActionRuntime, {
sendReactionWhatsApp,
});
});
it("adds reactions", async () => {

View File

@ -1,8 +1,18 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js";
import {
createActionGate,
jsonResult,
readReactionParams,
readStringParam,
} from "../../../src/agents/tools/common.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js";
import { sendReactionWhatsApp } from "./send.js";
export const whatsAppActionRuntime = {
resolveAuthorizedWhatsAppOutboundTarget,
sendReactionWhatsApp,
};
export async function handleWhatsAppAction(
params: Record<string, unknown>,
@ -26,7 +36,7 @@ export async function handleWhatsAppAction(
const fromMe = typeof fromMeRaw === "boolean" ? fromMeRaw : undefined;
// Resolve account + allowFrom via shared account logic so auth and routing stay aligned.
const resolved = resolveAuthorizedWhatsAppOutboundTarget({
const resolved = whatsAppActionRuntime.resolveAuthorizedWhatsAppOutboundTarget({
cfg,
chatJid,
accountId,
@ -34,7 +44,7 @@ export async function handleWhatsAppAction(
});
const resolvedEmoji = remove ? "" : emoji;
await sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, {
await whatsAppActionRuntime.sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, {
verbose: false,
fromMe,
participant: participant ?? undefined,

View File

@ -714,7 +714,7 @@
"overrides": {
"hono": "4.12.7",
"@hono/node-server": "1.19.10",
"fast-xml-parser": "5.3.8",
"fast-xml-parser": "5.5.6",
"request": "npm:@cypress/request@3.0.10",
"request-promise": "npm:@cypress/request-promise@5.0.0",
"file-type": "21.3.2",

59
pnpm-lock.yaml generated
View File

@ -7,7 +7,7 @@ settings:
overrides:
hono: 4.12.7
'@hono/node-server': 1.19.10
fast-xml-parser: 5.3.8
fast-xml-parser: 5.5.6
request: npm:@cypress/request@3.0.10
request-promise: npm:@cypress/request-promise@5.0.0
file-type: 21.3.2
@ -1411,8 +1411,8 @@ packages:
peerDependencies:
hono: 4.12.7
'@huggingface/jinja@0.5.5':
resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==}
'@huggingface/jinja@0.5.6':
resolution: {integrity: sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==}
engines: {node: '>=18'}
'@img/colour@1.0.0':
@ -3823,8 +3823,8 @@ packages:
audio-decode@2.2.3:
resolution: {integrity: sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==}
audio-type@2.2.1:
resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==}
audio-type@2.4.0:
resolution: {integrity: sha512-ugYMgxLpH6gyWUhFWFl2HCJboFL5z/GoqSdonx8ZycfNP8JDHBhRNzYWzrCRa/6htOWfvJAq7qpRloxvx06sRA==}
engines: {node: '>=14'}
aws-sign2@0.7.0:
@ -4504,8 +4504,11 @@ packages:
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-xml-parser@5.3.8:
resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==}
fast-xml-builder@1.1.4:
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
fast-xml-parser@5.5.6:
resolution: {integrity: sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==}
hasBin: true
fastq@1.20.1:
@ -5426,8 +5429,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
nanoid@5.1.7:
resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==}
engines: {node: ^18 || >=20}
hasBin: true
@ -5717,6 +5720,10 @@ packages:
partial-json@0.1.7:
resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
path-expression-matcher@1.1.3:
resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==}
engines: {node: '>=14.0.0'}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
@ -6206,8 +6213,8 @@ packages:
peerDependencies:
signal-polyfill: ^0.2.0
simple-git@3.32.3:
resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==}
simple-git@3.33.0:
resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==}
simple-yenc@1.0.4:
resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==}
@ -7788,13 +7795,13 @@ snapshots:
'@aws-sdk/xml-builder@3.972.11':
dependencies:
'@smithy/types': 4.13.1
fast-xml-parser: 5.3.8
fast-xml-parser: 5.5.6
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.8':
dependencies:
'@smithy/types': 4.13.0
fast-xml-parser: 5.3.8
fast-xml-parser: 5.5.6
tslib: 2.8.1
'@aws/lambda-invoke-store@0.2.3': {}
@ -8244,7 +8251,7 @@ snapshots:
dependencies:
hono: 4.12.7
'@huggingface/jinja@0.5.5': {}
'@huggingface/jinja@0.5.6': {}
'@img/colour@1.0.0': {}
@ -10942,14 +10949,14 @@ snapshots:
'@wasm-audio-decoders/flac': 0.2.10
'@wasm-audio-decoders/ogg-vorbis': 0.1.20
audio-buffer: 5.0.0
audio-type: 2.2.1
audio-type: 2.4.0
mpg123-decoder: 1.0.3
node-wav: 0.0.2
ogg-opus-decoder: 1.7.3
qoa-format: 1.0.1
optional: true
audio-type@2.2.1:
audio-type@2.4.0:
optional: true
aws-sign2@0.7.0: {}
@ -11667,8 +11674,14 @@ snapshots:
fast-uri@3.1.0: {}
fast-xml-parser@5.3.8:
fast-xml-builder@1.1.4:
dependencies:
path-expression-matcher: 1.1.3
fast-xml-parser@5.5.6:
dependencies:
fast-xml-builder: 1.1.4
path-expression-matcher: 1.1.3
strnum: 2.2.0
fastq@1.20.1:
@ -12681,7 +12694,7 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.6: {}
nanoid@5.1.7: {}
negotiator@0.6.3: {}
@ -12717,7 +12730,7 @@ snapshots:
node-llama-cpp@3.16.2(typescript@5.9.3):
dependencies:
'@huggingface/jinja': 0.5.5
'@huggingface/jinja': 0.5.6
async-retry: 1.3.3
bytes: 3.1.2
chalk: 5.6.2
@ -12732,14 +12745,14 @@ snapshots:
is-unicode-supported: 2.1.0
lifecycle-utils: 3.1.1
log-symbols: 7.0.1
nanoid: 5.1.6
nanoid: 5.1.7
node-addon-api: 8.6.0
octokit: 5.0.5
ora: 9.3.0
pretty-ms: 9.3.0
proper-lockfile: 4.1.2
semver: 7.7.4
simple-git: 3.32.3
simple-git: 3.33.0
slice-ansi: 8.0.0
stdout-update: 4.0.1
strip-ansi: 7.2.0
@ -13109,6 +13122,8 @@ snapshots:
partial-json@0.1.7: {}
path-expression-matcher@1.1.3: {}
path-is-absolute@1.0.1:
optional: true
@ -13727,7 +13742,7 @@ snapshots:
dependencies:
signal-polyfill: 0.2.2
simple-git@3.32.3:
simple-git@3.33.0:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1

View File

@ -1,12 +1,10 @@
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import {
createMessageActionDiscoveryContext,
resolveMessageActionDiscoveryChannelId,
} from "../channels/plugins/message-action-discovery.js";
import {
__testing as messageActionTesting,
resolveMessageActionDiscoveryForPlugin,
} from "../channels/plugins/message-actions.js";
resolveMessageActionDiscoveryChannelId,
__testing as messageActionTesting,
} from "../channels/plugins/message-action-discovery.js";
import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js";
import { normalizeAnyChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
@ -32,7 +30,7 @@ export function listChannelSupportedActions(params: {
return [];
}
const plugin = getChannelPlugin(channelId as Parameters<typeof getChannelPlugin>[0]);
if (!plugin?.actions?.listActions) {
if (!plugin?.actions) {
return [];
}
return resolveMessageActionDiscoveryForPlugin({

View File

@ -0,0 +1,417 @@
import { vi, type Mock } from "vitest";
type MockResolvedModel = {
model: { provider: string; api: string; id: string; input: unknown[] };
error: null;
authStorage: { setRuntimeApiKey: Mock<(provider?: string, apiKey?: string) => void> };
modelRegistry: Record<string, never>;
};
type MockMemorySearchManager = {
manager: {
sync: (params?: unknown) => Promise<void>;
};
};
export const contextEngineCompactMock = vi.fn(async () => ({
ok: true as boolean,
compacted: true as boolean,
reason: undefined as string | undefined,
result: { summary: "engine-summary", tokensAfter: 50 } as
| { summary: string; tokensAfter: number }
| undefined,
}));
export const hookRunner = {
hasHooks: vi.fn<(hookName?: string) => boolean>(),
runBeforeCompaction: vi.fn(async () => undefined),
runAfterCompaction: vi.fn(async () => undefined),
};
export const ensureRuntimePluginsLoaded: Mock<(params?: unknown) => void> = vi.fn();
export const resolveContextEngineMock = vi.fn(async () => ({
info: { ownsCompaction: true as boolean },
compact: contextEngineCompactMock,
}));
export const resolveModelMock: Mock<
(provider?: string, modelId?: string, agentDir?: string, cfg?: unknown) => MockResolvedModel
> = vi.fn((_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
}));
export const sessionCompactImpl = vi.fn(async () => ({
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
details: { ok: true },
}));
export const triggerInternalHook: Mock<(event?: unknown) => void> = vi.fn();
export const sanitizeSessionHistoryMock = vi.fn(
async (params: { messages: unknown[] }) => params.messages,
);
export const getMemorySearchManagerMock: Mock<
(params?: unknown) => Promise<MockMemorySearchManager>
> = vi.fn(async () => ({
manager: {
sync: vi.fn(async (_params?: unknown) => {}),
},
}));
export const resolveMemorySearchConfigMock = vi.fn(() => ({
sources: ["sessions"],
sync: {
sessions: {
postCompactionForce: true,
},
},
}));
export const resolveSessionAgentIdMock = vi.fn(() => "main");
export const estimateTokensMock = vi.fn((_message?: unknown) => 10);
export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.fn();
export const createOpenClawCodingToolsMock = vi.fn(() => []);
export function resetCompactHooksHarnessMocks(): void {
hookRunner.hasHooks.mockReset();
hookRunner.hasHooks.mockReturnValue(false);
hookRunner.runBeforeCompaction.mockReset();
hookRunner.runBeforeCompaction.mockResolvedValue(undefined);
hookRunner.runAfterCompaction.mockReset();
hookRunner.runAfterCompaction.mockResolvedValue(undefined);
ensureRuntimePluginsLoaded.mockReset();
resolveContextEngineMock.mockReset();
resolveContextEngineMock.mockResolvedValue({
info: { ownsCompaction: true },
compact: contextEngineCompactMock,
});
contextEngineCompactMock.mockReset();
contextEngineCompactMock.mockResolvedValue({
ok: true,
compacted: true,
reason: undefined,
result: { summary: "engine-summary", tokensAfter: 50 },
});
resolveModelMock.mockReset();
resolveModelMock.mockReturnValue({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
});
sessionCompactImpl.mockReset();
sessionCompactImpl.mockResolvedValue({
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
details: { ok: true },
});
triggerInternalHook.mockReset();
sanitizeSessionHistoryMock.mockReset();
sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => {
return params.messages;
});
getMemorySearchManagerMock.mockReset();
getMemorySearchManagerMock.mockResolvedValue({
manager: {
sync: vi.fn(async () => {}),
},
});
resolveMemorySearchConfigMock.mockReset();
resolveMemorySearchConfigMock.mockReturnValue({
sources: ["sessions"],
sync: {
sessions: {
postCompactionForce: true,
},
},
});
resolveSessionAgentIdMock.mockReset();
resolveSessionAgentIdMock.mockReturnValue("main");
estimateTokensMock.mockReset();
estimateTokensMock.mockReturnValue(10);
sessionAbortCompactionMock.mockReset();
createOpenClawCodingToolsMock.mockReset();
createOpenClawCodingToolsMock.mockReturnValue([]);
}
export async function loadCompactHooksHarness(): Promise<{
compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect;
compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession;
onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate;
}> {
resetCompactHooksHarnessMocks();
vi.resetModules();
vi.doMock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookRunner,
}));
vi.doMock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded,
}));
vi.doMock("../../hooks/internal-hooks.js", async () => {
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
"../../hooks/internal-hooks.js",
);
return {
...actual,
triggerInternalHook,
};
});
vi.doMock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: vi.fn(),
getOAuthProviders: vi.fn(() => []),
}));
vi.doMock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: class AuthStorage {},
ModelRegistry: class ModelRegistry {},
createAgentSession: vi.fn(async () => {
const session = {
sessionId: "session-1",
messages: [
{ role: "user", content: "hello", timestamp: 1 },
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 },
{
role: "toolResult",
toolCallId: "t1",
toolName: "exec",
content: [{ type: "text", text: "output" }],
isError: false,
timestamp: 3,
},
],
agent: {
replaceMessages: vi.fn((messages: unknown[]) => {
session.messages = [...(messages as typeof session.messages)];
}),
streamFn: vi.fn(),
},
compact: vi.fn(async () => {
session.messages.splice(1);
return await sessionCompactImpl();
}),
abortCompaction: sessionAbortCompactionMock,
dispose: vi.fn(),
};
return { session };
}),
DefaultResourceLoader: class DefaultResourceLoader {},
SessionManager: {
open: vi.fn(() => ({})),
},
SettingsManager: {
create: vi.fn(() => ({})),
},
estimateTokens: estimateTokensMock,
}));
vi.doMock("../session-tool-result-guard-wrapper.js", () => ({
guardSessionManager: vi.fn(() => ({
flushPendingToolResults: vi.fn(),
})),
}));
vi.doMock("../pi-settings.js", () => ({
ensurePiCompactionReserveTokens: vi.fn(),
resolveCompactionReserveTokensFloor: vi.fn(() => 0),
}));
vi.doMock("../models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("../model-auth.js", () => ({
applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model),
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })),
resolveModelAuthMode: vi.fn(() => "env"),
}));
vi.doMock("../sandbox.js", () => ({
resolveSandboxContext: vi.fn(async () => null),
}));
vi.doMock("../session-file-repair.js", () => ({
repairSessionFileIfNeeded: vi.fn(async () => {}),
}));
vi.doMock("../session-write-lock.js", () => ({
acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })),
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
}));
vi.doMock("../../context-engine/index.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: resolveContextEngineMock,
}));
vi.doMock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()),
}));
vi.doMock("./lanes.js", () => ({
resolveSessionLane: vi.fn(() => "test-session-lane"),
resolveGlobalLane: vi.fn(() => "test-global-lane"),
}));
vi.doMock("../context-window-guard.js", () => ({
resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })),
}));
vi.doMock("../bootstrap-files.js", () => ({
makeBootstrapWarn: vi.fn(() => () => {}),
resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })),
}));
vi.doMock("../docs-path.js", () => ({
resolveOpenClawDocsPath: vi.fn(async () => undefined),
}));
vi.doMock("../channel-tools.js", () => ({
listChannelSupportedActions: vi.fn(() => undefined),
resolveChannelMessageToolHints: vi.fn(() => undefined),
}));
vi.doMock("../pi-tools.js", () => ({
createOpenClawCodingTools: createOpenClawCodingToolsMock,
}));
vi.doMock("./google.js", () => ({
logToolSchemasForGoogle: vi.fn(),
sanitizeSessionHistory: sanitizeSessionHistoryMock,
sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools),
}));
vi.doMock("./tool-split.js", () => ({
splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })),
}));
vi.doMock("../transcript-policy.js", () => ({
resolveTranscriptPolicy: vi.fn(() => ({
allowSyntheticToolResults: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
})),
}));
vi.doMock("./extensions.js", () => ({
buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })),
}));
vi.doMock("./history.js", () => ({
getDmHistoryLimitFromSessionKey: vi.fn(() => undefined),
limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)),
}));
vi.doMock("../skills.js", () => ({
applySkillEnvOverrides: vi.fn(() => () => {}),
applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}),
loadWorkspaceSkillEntries: vi.fn(() => []),
resolveSkillsPromptForRun: vi.fn(() => undefined),
}));
vi.doMock("../agent-paths.js", () => ({
resolveOpenClawAgentDir: vi.fn(() => "/tmp"),
}));
vi.doMock("../agent-scope.js", () => ({
resolveSessionAgentId: resolveSessionAgentIdMock,
resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })),
}));
vi.doMock("../memory-search.js", () => ({
resolveMemorySearchConfig: resolveMemorySearchConfigMock,
}));
vi.doMock("../../memory/index.js", () => ({
getMemorySearchManager: getMemorySearchManagerMock,
}));
vi.doMock("../date-time.js", () => ({
formatUserTime: vi.fn(() => ""),
resolveUserTimeFormat: vi.fn(() => ""),
resolveUserTimezone: vi.fn(() => ""),
}));
vi.doMock("../defaults.js", () => ({
DEFAULT_MODEL: "fake-model",
DEFAULT_PROVIDER: "openai",
DEFAULT_CONTEXT_TOKENS: 128_000,
}));
vi.doMock("../utils.js", () => ({
resolveUserPath: vi.fn((p: string) => p),
}));
vi.doMock("../../infra/machine-name.js", () => ({
getMachineDisplayName: vi.fn(async () => "machine"),
}));
vi.doMock("../../config/channel-capabilities.js", () => ({
resolveChannelCapabilities: vi.fn(() => undefined),
}));
vi.doMock("../../utils/message-channel.js", () => ({
INTERNAL_MESSAGE_CHANNEL: "webchat",
normalizeMessageChannel: vi.fn(() => undefined),
}));
vi.doMock("../pi-embedded-helpers.js", () => ({
ensureSessionHeader: vi.fn(async () => {}),
validateAnthropicTurns: vi.fn((m: unknown[]) => m),
validateGeminiTurns: vi.fn((m: unknown[]) => m),
}));
vi.doMock("../pi-project-settings.js", () => ({
createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({
getGlobalSettings: vi.fn(() => ({})),
})),
}));
vi.doMock("./sandbox-info.js", () => ({
buildEmbeddedSandboxInfo: vi.fn(() => undefined),
}));
vi.doMock("./model.js", () => ({
buildModelAliasLines: vi.fn(() => []),
resolveModel: resolveModelMock,
resolveModelAsync: vi.fn(
async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) =>
resolveModelMock(provider, modelId, agentDir, cfg),
),
}));
vi.doMock("./session-manager-cache.js", () => ({
prewarmSessionFile: vi.fn(async () => {}),
trackSessionManagerAccess: vi.fn(),
}));
vi.doMock("./system-prompt.js", () => ({
applySystemPromptOverrideToSession: vi.fn(),
buildEmbeddedSystemPrompt: vi.fn(() => ""),
createSystemPromptOverride: vi.fn(() => () => ""),
}));
vi.doMock("./utils.js", () => ({
describeUnknownError: vi.fn((err: unknown) => String(err)),
mapThinkingLevel: vi.fn(() => "off"),
resolveExecToolDefaults: vi.fn(() => undefined),
}));
const [compactModule, transcriptEvents] = await Promise.all([
import("./compact.js"),
import("../../sessions/transcript-events.js"),
]);
return {
...compactModule,
onSessionTranscriptUpdate: transcriptEvents.onSessionTranscriptUpdate,
};
}

View File

@ -1,348 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
const {
hookRunner,
import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getCustomApiRegistrySourceId } from "../custom-api-registry.js";
import {
contextEngineCompactMock,
createOpenClawCodingToolsMock,
ensureRuntimePluginsLoaded,
estimateTokensMock,
getMemorySearchManagerMock,
hookRunner,
loadCompactHooksHarness,
resolveContextEngineMock,
resolveMemorySearchConfigMock,
resolveModelMock,
resolveSessionAgentIdMock,
resetCompactHooksHarnessMocks,
sanitizeSessionHistoryMock,
sessionAbortCompactionMock,
sessionCompactImpl,
triggerInternalHook,
sanitizeSessionHistoryMock,
contextEngineCompactMock,
getMemorySearchManagerMock,
resolveMemorySearchConfigMock,
resolveSessionAgentIdMock,
estimateTokensMock,
sessionAbortCompactionMock,
createOpenClawCodingToolsMock,
} = vi.hoisted(() => {
const contextEngineCompactMock = vi.fn(async () => ({
ok: true as boolean,
compacted: true as boolean,
reason: undefined as string | undefined,
result: { summary: "engine-summary", tokensAfter: 50 } as
| { summary: string; tokensAfter: number }
| undefined,
}));
} from "./compact.hooks.harness.js";
return {
hookRunner: {
hasHooks: vi.fn(),
runBeforeCompaction: vi.fn(),
runAfterCompaction: vi.fn(),
},
ensureRuntimePluginsLoaded: vi.fn(),
resolveContextEngineMock: vi.fn(async () => ({
info: { ownsCompaction: true },
compact: contextEngineCompactMock,
})),
resolveModelMock: vi.fn(
(_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
}),
),
sessionCompactImpl: vi.fn(async () => ({
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
details: { ok: true },
})),
triggerInternalHook: vi.fn(),
sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages),
contextEngineCompactMock,
getMemorySearchManagerMock: vi.fn(async () => ({
manager: {
sync: vi.fn(async () => {}),
},
})),
resolveMemorySearchConfigMock: vi.fn(() => ({
sources: ["sessions"],
sync: {
sessions: {
postCompactionForce: true,
},
},
})),
resolveSessionAgentIdMock: vi.fn(() => "main"),
estimateTokensMock: vi.fn((_message?: unknown) => 10),
sessionAbortCompactionMock: vi.fn(),
createOpenClawCodingToolsMock: vi.fn(() => []),
};
});
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookRunner,
}));
vi.mock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded,
}));
vi.mock("../../hooks/internal-hooks.js", async () => {
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
"../../hooks/internal-hooks.js",
);
return {
...actual,
triggerInternalHook,
};
});
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: vi.fn(),
getOAuthProviders: vi.fn(() => []),
}));
vi.mock("@mariozechner/pi-coding-agent", () => {
return {
AuthStorage: class AuthStorage {},
ModelRegistry: class ModelRegistry {},
createAgentSession: vi.fn(async () => {
const session = {
sessionId: "session-1",
messages: [
{ role: "user", content: "hello", timestamp: 1 },
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 },
{
role: "toolResult",
toolCallId: "t1",
toolName: "exec",
content: [{ type: "text", text: "output" }],
isError: false,
timestamp: 3,
},
],
agent: {
replaceMessages: vi.fn((messages: unknown[]) => {
session.messages = [...(messages as typeof session.messages)];
}),
streamFn: vi.fn(),
},
compact: vi.fn(async () => {
// simulate compaction trimming to a single message
session.messages.splice(1);
return await sessionCompactImpl();
}),
abortCompaction: sessionAbortCompactionMock,
dispose: vi.fn(),
};
return { session };
}),
SessionManager: {
open: vi.fn(() => ({})),
},
SettingsManager: {
create: vi.fn(() => ({})),
},
estimateTokens: estimateTokensMock,
};
});
vi.mock("../session-tool-result-guard-wrapper.js", () => ({
guardSessionManager: vi.fn(() => ({
flushPendingToolResults: vi.fn(),
})),
}));
vi.mock("../pi-settings.js", () => ({
ensurePiCompactionReserveTokens: vi.fn(),
resolveCompactionReserveTokensFloor: vi.fn(() => 0),
}));
vi.mock("../models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.mock("../model-auth.js", () => ({
applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model),
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })),
resolveModelAuthMode: vi.fn(() => "env"),
}));
vi.mock("../sandbox.js", () => ({
resolveSandboxContext: vi.fn(async () => null),
}));
vi.mock("../session-file-repair.js", () => ({
repairSessionFileIfNeeded: vi.fn(async () => {}),
}));
vi.mock("../session-write-lock.js", () => ({
acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })),
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
}));
vi.mock("../../context-engine/index.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: resolveContextEngineMock,
}));
vi.mock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()),
}));
vi.mock("./lanes.js", () => ({
resolveSessionLane: vi.fn(() => "test-session-lane"),
resolveGlobalLane: vi.fn(() => "test-global-lane"),
}));
vi.mock("../context-window-guard.js", () => ({
resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })),
}));
vi.mock("../bootstrap-files.js", () => ({
makeBootstrapWarn: vi.fn(() => () => {}),
resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })),
}));
vi.mock("../docs-path.js", () => ({
resolveOpenClawDocsPath: vi.fn(async () => undefined),
}));
vi.mock("../channel-tools.js", () => ({
listChannelSupportedActions: vi.fn(() => undefined),
resolveChannelMessageToolHints: vi.fn(() => undefined),
}));
vi.mock("../pi-tools.js", () => ({
createOpenClawCodingTools: createOpenClawCodingToolsMock,
}));
vi.mock("./google.js", () => ({
logToolSchemasForGoogle: vi.fn(),
sanitizeSessionHistory: sanitizeSessionHistoryMock,
sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools),
}));
vi.mock("./tool-split.js", () => ({
splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })),
}));
vi.mock("../transcript-policy.js", () => ({
resolveTranscriptPolicy: vi.fn(() => ({
allowSyntheticToolResults: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
})),
}));
vi.mock("./extensions.js", () => ({
buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })),
}));
vi.mock("./history.js", () => ({
getDmHistoryLimitFromSessionKey: vi.fn(() => undefined),
limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)),
}));
vi.mock("../skills.js", () => ({
applySkillEnvOverrides: vi.fn(() => () => {}),
applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}),
loadWorkspaceSkillEntries: vi.fn(() => []),
resolveSkillsPromptForRun: vi.fn(() => undefined),
}));
vi.mock("../agent-paths.js", () => ({
resolveOpenClawAgentDir: vi.fn(() => "/tmp"),
}));
vi.mock("../agent-scope.js", () => ({
resolveSessionAgentId: resolveSessionAgentIdMock,
resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })),
}));
vi.mock("../memory-search.js", () => ({
resolveMemorySearchConfig: resolveMemorySearchConfigMock,
}));
vi.mock("../../memory/index.js", () => ({
getMemorySearchManager: getMemorySearchManagerMock,
}));
vi.mock("../date-time.js", () => ({
formatUserTime: vi.fn(() => ""),
resolveUserTimeFormat: vi.fn(() => ""),
resolveUserTimezone: vi.fn(() => ""),
}));
vi.mock("../defaults.js", () => ({
DEFAULT_MODEL: "fake-model",
DEFAULT_PROVIDER: "openai",
DEFAULT_CONTEXT_TOKENS: 128_000,
}));
vi.mock("../utils.js", () => ({
resolveUserPath: vi.fn((p: string) => p),
}));
vi.mock("../../infra/machine-name.js", () => ({
getMachineDisplayName: vi.fn(async () => "machine"),
}));
vi.mock("../../config/channel-capabilities.js", () => ({
resolveChannelCapabilities: vi.fn(() => undefined),
}));
vi.mock("../../utils/message-channel.js", () => ({
INTERNAL_MESSAGE_CHANNEL: "webchat",
normalizeMessageChannel: vi.fn(() => undefined),
}));
vi.mock("../pi-embedded-helpers.js", () => ({
ensureSessionHeader: vi.fn(async () => {}),
validateAnthropicTurns: vi.fn((m: unknown[]) => m),
validateGeminiTurns: vi.fn((m: unknown[]) => m),
}));
vi.mock("../pi-project-settings.js", () => ({
createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({
getGlobalSettings: vi.fn(() => ({})),
})),
}));
vi.mock("./sandbox-info.js", () => ({
buildEmbeddedSandboxInfo: vi.fn(() => undefined),
}));
vi.mock("./model.js", () => ({
buildModelAliasLines: vi.fn(() => []),
resolveModel: resolveModelMock,
resolveModelAsync: vi.fn(
async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) =>
resolveModelMock(provider, modelId, agentDir, cfg),
),
}));
vi.mock("./session-manager-cache.js", () => ({
prewarmSessionFile: vi.fn(async () => {}),
trackSessionManagerAccess: vi.fn(),
}));
vi.mock("./system-prompt.js", () => ({
applySystemPromptOverrideToSession: vi.fn(),
buildEmbeddedSystemPrompt: vi.fn(() => ""),
createSystemPromptOverride: vi.fn(() => () => ""),
}));
vi.mock("./utils.js", () => ({
describeUnknownError: vi.fn((err: unknown) => String(err)),
mapThinkingLevel: vi.fn(() => "off"),
resolveExecToolDefaults: vi.fn(() => undefined),
}));
import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai";
import { getCustomApiRegistrySourceId } from "../custom-api-registry.js";
import { compactEmbeddedPiSessionDirect, compactEmbeddedPiSession } from "./compact.js";
let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect;
let compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession;
let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate;
const TEST_SESSION_ID = "session-1";
const TEST_SESSION_KEY = "agent:main:session-1";
const TEST_SESSION_FILE = "/tmp/session.jsonl";
const TEST_WORKSPACE_DIR = "/tmp";
const TEST_CUSTOM_INSTRUCTIONS = "focus on decisions";
type SessionHookEvent = {
type?: string;
action?: string;
sessionKey?: string;
context?: Record<string, unknown>;
};
type PostCompactionSyncParams = {
reason: string;
sessionFiles: string[];
};
type PostCompactionSync = (params?: unknown) => Promise<void>;
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
function mockResolvedModel() {
resolveModelMock.mockReset();
@ -389,10 +98,22 @@ function wrappedCompactionArgs(overrides: Record<string, unknown> = {}) {
};
}
const sessionHook = (action: string) =>
triggerInternalHook.mock.calls.find(
(call) => call[0]?.type === "session" && call[0]?.action === action,
)?.[0];
const sessionHook = (action: string): SessionHookEvent | undefined =>
triggerInternalHook.mock.calls.find((call) => {
const event = call[0] as SessionHookEvent | undefined;
return event?.type === "session" && event.action === action;
})?.[0] as SessionHookEvent | undefined;
beforeAll(async () => {
const loaded = await loadCompactHooksHarness();
compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect;
compactEmbeddedPiSession = loaded.compactEmbeddedPiSession;
onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate;
});
beforeEach(() => {
resetCompactHooksHarnessMocks();
});
describe("compactEmbeddedPiSessionDirect hooks", () => {
beforeEach(() => {
@ -689,11 +410,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
});
it("awaits post-compaction memory sync in await mode when postCompactionForce is true", async () => {
let releaseSync: (() => void) | undefined;
const syncGate = new Promise<void>((resolve) => {
releaseSync = resolve;
const syncStarted = createDeferred<PostCompactionSyncParams>();
const syncRelease = createDeferred<void>();
const sync = vi.fn<PostCompactionSync>(async (params) => {
syncStarted.resolve(params as PostCompactionSyncParams);
await syncRelease.promise;
});
const sync = vi.fn(() => syncGate);
getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } });
let settled = false;
@ -706,14 +428,12 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
void resultPromise.then(() => {
settled = true;
});
await vi.waitFor(() => {
expect(sync).toHaveBeenCalledWith({
reason: "post-compaction",
sessionFiles: [TEST_SESSION_FILE],
});
await expect(syncStarted.promise).resolves.toEqual({
reason: "post-compaction",
sessionFiles: [TEST_SESSION_FILE],
});
expect(settled).toBe(false);
releaseSync?.();
syncRelease.resolve(undefined);
const result = await resultPromise;
expect(result.ok).toBe(true);
expect(settled).toBe(true);
@ -736,12 +456,17 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
});
it("fires post-compaction memory sync without awaiting it in async mode", async () => {
const sync = vi.fn(async () => {});
let resolveManager: ((value: { manager: { sync: typeof sync } }) => void) | undefined;
const managerGate = new Promise<{ manager: { sync: typeof sync } }>((resolve) => {
resolveManager = resolve;
const sync = vi.fn<PostCompactionSync>(async () => {});
const managerRequested = createDeferred<void>();
const managerGate = createDeferred<{ manager: { sync: PostCompactionSync } }>();
const syncStarted = createDeferred<PostCompactionSyncParams>();
sync.mockImplementation(async (params) => {
syncStarted.resolve(params as PostCompactionSyncParams);
});
getMemorySearchManagerMock.mockImplementation(async () => {
managerRequested.resolve(undefined);
return await managerGate.promise;
});
getMemorySearchManagerMock.mockImplementation(() => managerGate);
let settled = false;
const resultPromise = compactEmbeddedPiSessionDirect(
@ -750,26 +475,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
}),
);
await vi.waitFor(() => {
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1);
});
await managerRequested.promise;
void resultPromise.then(() => {
settled = true;
});
await vi.waitFor(() => {
expect(settled).toBe(true);
});
await resultPromise;
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1);
expect(settled).toBe(true);
expect(sync).not.toHaveBeenCalled();
resolveManager?.({ manager: { sync } });
await managerGate;
await vi.waitFor(() => {
expect(sync).toHaveBeenCalledWith({
reason: "post-compaction",
sessionFiles: [TEST_SESSION_FILE],
});
managerGate.resolve({ manager: { sync } });
await expect(syncStarted.promise).resolves.toEqual({
reason: "post-compaction",
sessionFiles: [TEST_SESSION_FILE],
});
const result = await resultPromise;
expect(result.ok).toBe(true);
});
it("registers the Ollama api provider before compaction", async () => {

View File

@ -91,6 +91,7 @@ import {
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js";
import { buildModelAliasLines, resolveModelAsync } from "./model.js";
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
@ -113,6 +114,11 @@ export type CompactEmbeddedPiSessionParams = {
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
/** Trusted sender id from inbound context for scoped message-tool discovery. */
senderId?: string;
authProfileId?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
@ -655,14 +661,20 @@ export async function compactEmbeddedPiSessionDirect(
});
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: sessionAgentId,
})
? listChannelSupportedActions(
buildEmbeddedMessageActionDiscoveryInput({
cfg: params.config,
channel: runtimeChannel,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.agentAccountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: sessionAgentId,
senderId: params.senderId,
}),
)
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js";
describe("buildEmbeddedCompactionRuntimeContext", () => {
it("preserves sender and current message routing for compaction", () => {
expect(
buildEmbeddedCompactionRuntimeContext({
sessionKey: "agent:main:thread:1",
messageChannel: "slack",
messageProvider: "slack",
agentAccountId: "acct-1",
currentChannelId: "C123",
currentThreadTs: "thread-9",
currentMessageId: "msg-42",
authProfileId: "openai:p1",
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
config: {} as OpenClawConfig,
senderIsOwner: true,
senderId: "user-123",
provider: "openai-codex",
modelId: "gpt-5.3-codex",
thinkLevel: "off",
reasoningLevel: "on",
extraSystemPrompt: "extra",
ownerNumbers: ["+15555550123"],
}),
).toMatchObject({
sessionKey: "agent:main:thread:1",
messageChannel: "slack",
messageProvider: "slack",
agentAccountId: "acct-1",
currentChannelId: "C123",
currentThreadTs: "thread-9",
currentMessageId: "msg-42",
authProfileId: "openai:p1",
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
senderId: "user-123",
provider: "openai-codex",
model: "gpt-5.3-codex",
});
});
it("normalizes nullable compaction routing fields to undefined", () => {
expect(
buildEmbeddedCompactionRuntimeContext({
sessionKey: null,
messageChannel: null,
messageProvider: null,
agentAccountId: null,
currentChannelId: null,
currentThreadTs: null,
currentMessageId: null,
authProfileId: null,
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
senderId: null,
provider: null,
modelId: null,
}),
).toMatchObject({
sessionKey: undefined,
messageChannel: undefined,
messageProvider: undefined,
agentAccountId: undefined,
currentChannelId: undefined,
currentThreadTs: undefined,
currentMessageId: undefined,
authProfileId: undefined,
senderId: undefined,
provider: undefined,
model: undefined,
});
});
});

View File

@ -0,0 +1,76 @@
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { ExecElevatedDefaults } from "../bash-tools.js";
import type { SkillSnapshot } from "../skills.js";
export type EmbeddedCompactionRuntimeContext = {
sessionKey?: string;
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
authProfileId?: string;
workspaceDir: string;
agentDir: string;
config?: OpenClawConfig;
skillsSnapshot?: SkillSnapshot;
senderIsOwner?: boolean;
senderId?: string;
provider?: string;
model?: string;
thinkLevel?: ThinkLevel;
reasoningLevel?: ReasoningLevel;
bashElevated?: ExecElevatedDefaults;
extraSystemPrompt?: string;
ownerNumbers?: string[];
};
export function buildEmbeddedCompactionRuntimeContext(params: {
sessionKey?: string | null;
messageChannel?: string | null;
messageProvider?: string | null;
agentAccountId?: string | null;
currentChannelId?: string | null;
currentThreadTs?: string | null;
currentMessageId?: string | number | null;
authProfileId?: string | null;
workspaceDir: string;
agentDir: string;
config?: OpenClawConfig;
skillsSnapshot?: SkillSnapshot;
senderIsOwner?: boolean;
senderId?: string | null;
provider?: string | null;
modelId?: string | null;
thinkLevel?: ThinkLevel;
reasoningLevel?: ReasoningLevel;
bashElevated?: ExecElevatedDefaults;
extraSystemPrompt?: string;
ownerNumbers?: string[];
}): EmbeddedCompactionRuntimeContext {
return {
sessionKey: params.sessionKey ?? undefined,
messageChannel: params.messageChannel ?? undefined,
messageProvider: params.messageProvider ?? undefined,
agentAccountId: params.agentAccountId ?? undefined,
currentChannelId: params.currentChannelId ?? undefined,
currentThreadTs: params.currentThreadTs ?? undefined,
currentMessageId: params.currentMessageId ?? undefined,
authProfileId: params.authProfileId ?? undefined,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
senderId: params.senderId ?? undefined,
provider: params.provider ?? undefined,
model: params.modelId ?? undefined,
thinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
};
}

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-discovery-input.js";
describe("buildEmbeddedMessageActionDiscoveryInput", () => {
it("maps sender and routing scope into message-action discovery context", () => {
expect(
buildEmbeddedMessageActionDiscoveryInput({
channel: "telegram",
currentChannelId: "chat-1",
currentThreadTs: "thread-9",
currentMessageId: "msg-42",
accountId: "acct-1",
sessionKey: "agent:main:thread:1",
sessionId: "session-1",
agentId: "main",
senderId: "user-123",
}),
).toEqual({
cfg: undefined,
channel: "telegram",
currentChannelId: "chat-1",
currentThreadTs: "thread-9",
currentMessageId: "msg-42",
accountId: "acct-1",
sessionKey: "agent:main:thread:1",
sessionId: "session-1",
agentId: "main",
requesterSenderId: "user-123",
});
});
it("normalizes nullable routing fields to undefined", () => {
expect(
buildEmbeddedMessageActionDiscoveryInput({
channel: "slack",
currentChannelId: null,
currentThreadTs: null,
currentMessageId: null,
accountId: null,
sessionKey: null,
sessionId: null,
agentId: null,
senderId: null,
}),
).toEqual({
cfg: undefined,
channel: "slack",
currentChannelId: undefined,
currentThreadTs: undefined,
currentMessageId: undefined,
accountId: undefined,
sessionKey: undefined,
sessionId: undefined,
agentId: undefined,
requesterSenderId: undefined,
});
});
});

View File

@ -0,0 +1,27 @@
import type { OpenClawConfig } from "../../config/config.js";
export function buildEmbeddedMessageActionDiscoveryInput(params: {
cfg?: OpenClawConfig;
channel: string;
currentChannelId?: string | null;
currentThreadTs?: string | null;
currentMessageId?: string | number | null;
accountId?: string | null;
sessionKey?: string | null;
sessionId?: string | null;
agentId?: string | null;
senderId?: string | null;
}) {
return {
cfg: params.cfg,
channel: params.channel,
currentChannelId: params.currentChannelId ?? undefined,
currentThreadTs: params.currentThreadTs ?? undefined,
currentMessageId: params.currentMessageId ?? undefined,
accountId: params.accountId ?? undefined,
sessionKey: params.sessionKey ?? undefined,
sessionId: params.sessionId ?? undefined,
agentId: params.agentId ?? undefined,
requesterSenderId: params.senderId ?? undefined,
};
}

View File

@ -0,0 +1,406 @@
import { vi, type Mock } from "vitest";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type {
PluginHookAgentContext,
PluginHookBeforeAgentStartResult,
PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildResult,
} from "../../plugins/types.js";
import type { EmbeddedRunAttemptResult } from "./run/types.js";
type MockCompactionResult =
| {
ok: true;
compacted: true;
result: {
summary: string;
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
};
reason?: string;
}
| {
ok: false;
compacted: false;
reason: string;
result?: undefined;
};
export const mockedGlobalHookRunner = {
hasHooks: vi.fn((_hookName: string) => false),
runBeforeAgentStart: vi.fn(
async (
_event: { prompt: string; messages?: unknown[] },
_ctx: PluginHookAgentContext,
): Promise<PluginHookBeforeAgentStartResult | undefined> => undefined,
),
runBeforePromptBuild: vi.fn(
async (
_event: { prompt: string; messages: unknown[] },
_ctx: PluginHookAgentContext,
): Promise<PluginHookBeforePromptBuildResult | undefined> => undefined,
),
runBeforeModelResolve: vi.fn(
async (
_event: { prompt: string },
_ctx: PluginHookAgentContext,
): Promise<PluginHookBeforeModelResolveResult | undefined> => undefined,
),
runBeforeCompaction: vi.fn(async () => undefined),
runAfterCompaction: vi.fn(async () => undefined),
};
export const mockedContextEngine = {
info: { ownsCompaction: false as boolean },
compact: vi.fn<(params: unknown) => Promise<MockCompactionResult>>(async () => ({
ok: false as const,
compacted: false as const,
reason: "nothing to compact",
})),
};
export const mockedContextEngineCompact = mockedContextEngine.compact;
export const mockedCompactDirect = mockedContextEngine.compact;
export const mockedEnsureRuntimePluginsLoaded = vi.fn<(params?: unknown) => void>();
export const mockedPrepareProviderRuntimeAuth = vi.fn(async () => undefined);
export const mockedRunEmbeddedAttempt =
vi.fn<(params: unknown) => Promise<EmbeddedRunAttemptResult>>();
export const mockedSessionLikelyHasOversizedToolResults = vi.fn(() => false);
export const mockedTruncateOversizedToolResultsInSession = vi.fn<
() => Promise<MockTruncateOversizedToolResultsResult>
>(async () => ({
truncated: false,
truncatedCount: 0,
reason: "no oversized tool results",
}));
type MockFailoverErrorDescription = {
message: string;
reason: string | undefined;
status: number | undefined;
code: string | undefined;
};
type MockCoerceToFailoverError = (
err: unknown,
params?: { provider?: string; model?: string; profileId?: string },
) => unknown;
type MockDescribeFailoverError = (err: unknown) => MockFailoverErrorDescription;
type MockResolveFailoverStatus = (reason: string) => number | undefined;
type MockTruncateOversizedToolResultsResult = {
truncated: boolean;
truncatedCount: number;
reason?: string;
};
export const mockedCoerceToFailoverError = vi.fn<MockCoerceToFailoverError>();
export const mockedDescribeFailoverError = vi.fn<MockDescribeFailoverError>(
(err: unknown): MockFailoverErrorDescription => ({
message: err instanceof Error ? err.message : String(err),
reason: undefined,
status: undefined,
code: undefined,
}),
);
export const mockedResolveFailoverStatus = vi.fn<MockResolveFailoverStatus>();
export const mockedLog: {
debug: Mock<(...args: unknown[]) => void>;
info: Mock<(...args: unknown[]) => void>;
warn: Mock<(...args: unknown[]) => void>;
error: Mock<(...args: unknown[]) => void>;
isEnabled: Mock<(level?: string) => boolean>;
} = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
isEnabled: vi.fn(() => false),
};
export const mockedClassifyFailoverReason = vi.fn(() => null);
export const mockedExtractObservedOverflowTokenCount = vi.fn((msg?: string) => {
const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i);
return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined;
});
export const mockedIsCompactionFailureError = vi.fn(() => false);
export const mockedIsLikelyContextOverflowError = vi.fn((msg?: string) => {
const lower = (msg ?? "").toLowerCase();
return (
lower.includes("request_too_large") ||
lower.includes("context window exceeded") ||
lower.includes("prompt is too long")
);
});
export const mockedPickFallbackThinkingLevel = vi.fn<(params?: unknown) => ThinkLevel | null>(
() => null,
);
export const overflowBaseRunParams = {
sessionId: "test-session",
sessionKey: "test-key",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp/workspace",
prompt: "hello",
timeoutMs: 30000,
runId: "run-1",
} as const;
export function resetRunOverflowCompactionHarnessMocks(): void {
mockedGlobalHookRunner.hasHooks.mockReset();
mockedGlobalHookRunner.hasHooks.mockReturnValue(false);
mockedGlobalHookRunner.runBeforeAgentStart.mockReset();
mockedGlobalHookRunner.runBeforeAgentStart.mockResolvedValue(undefined);
mockedGlobalHookRunner.runBeforePromptBuild.mockReset();
mockedGlobalHookRunner.runBeforePromptBuild.mockResolvedValue(undefined);
mockedGlobalHookRunner.runBeforeModelResolve.mockReset();
mockedGlobalHookRunner.runBeforeModelResolve.mockResolvedValue(undefined);
mockedGlobalHookRunner.runBeforeCompaction.mockReset();
mockedGlobalHookRunner.runBeforeCompaction.mockResolvedValue(undefined);
mockedGlobalHookRunner.runAfterCompaction.mockReset();
mockedGlobalHookRunner.runAfterCompaction.mockResolvedValue(undefined);
mockedContextEngine.info.ownsCompaction = false;
mockedContextEngineCompact.mockReset();
mockedContextEngineCompact.mockResolvedValue({
ok: false,
compacted: false,
reason: "nothing to compact",
});
mockedEnsureRuntimePluginsLoaded.mockReset();
mockedPrepareProviderRuntimeAuth.mockReset();
mockedPrepareProviderRuntimeAuth.mockResolvedValue(undefined);
mockedRunEmbeddedAttempt.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false);
mockedTruncateOversizedToolResultsInSession.mockReset();
mockedTruncateOversizedToolResultsInSession.mockResolvedValue({
truncated: false,
truncatedCount: 0,
reason: "no oversized tool results",
});
mockedCoerceToFailoverError.mockReset();
mockedCoerceToFailoverError.mockReturnValue(null);
mockedDescribeFailoverError.mockReset();
mockedDescribeFailoverError.mockImplementation(
(err: unknown): MockFailoverErrorDescription => ({
message: err instanceof Error ? err.message : String(err),
reason: undefined,
status: undefined,
code: undefined,
}),
);
mockedResolveFailoverStatus.mockReset();
mockedResolveFailoverStatus.mockReturnValue(undefined);
mockedLog.debug.mockReset();
mockedLog.info.mockReset();
mockedLog.warn.mockReset();
mockedLog.error.mockReset();
mockedLog.isEnabled.mockReset();
mockedLog.isEnabled.mockReturnValue(false);
mockedClassifyFailoverReason.mockReset();
mockedClassifyFailoverReason.mockReturnValue(null);
mockedExtractObservedOverflowTokenCount.mockReset();
mockedExtractObservedOverflowTokenCount.mockImplementation((msg?: string) => {
const match = msg?.match(/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i);
return match?.[1] ? Number(match[1].replaceAll(",", "")) : undefined;
});
mockedIsCompactionFailureError.mockReset();
mockedIsCompactionFailureError.mockReturnValue(false);
mockedIsLikelyContextOverflowError.mockReset();
mockedIsLikelyContextOverflowError.mockImplementation((msg?: string) => {
const lower = (msg ?? "").toLowerCase();
return (
lower.includes("request_too_large") ||
lower.includes("context window exceeded") ||
lower.includes("prompt is too long")
);
});
mockedPickFallbackThinkingLevel.mockReset();
mockedPickFallbackThinkingLevel.mockReturnValue(null);
}
export async function loadRunOverflowCompactionHarness(): Promise<{
runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent;
}> {
resetRunOverflowCompactionHarnessMocks();
vi.resetModules();
vi.doMock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner),
}));
vi.doMock("../../context-engine/index.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: vi.fn(async () => mockedContextEngine),
}));
vi.doMock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded,
}));
vi.doMock("../../plugins/provider-runtime.js", () => ({
prepareProviderRuntimeAuth: mockedPrepareProviderRuntimeAuth,
}));
vi.doMock("../auth-profiles.js", () => ({
isProfileInCooldown: vi.fn(() => false),
markAuthProfileFailure: vi.fn(async () => {}),
markAuthProfileGood: vi.fn(async () => {}),
markAuthProfileUsed: vi.fn(async () => {}),
resolveProfilesUnavailableReason: vi.fn(() => undefined),
}));
vi.doMock("../usage.js", () => ({
normalizeUsage: vi.fn((usage?: unknown) =>
usage && typeof usage === "object" ? usage : undefined,
),
derivePromptTokens: vi.fn(
(usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) =>
usage
? (() => {
const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
return sum > 0 ? sum : undefined;
})()
: undefined,
),
}));
vi.doMock("../workspace-run.js", () => ({
resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({
workspaceDir: params.workspaceDir,
usedFallback: false,
fallbackReason: undefined,
agentId: "main",
})),
redactRunIdentifier: vi.fn((value?: string) => value ?? ""),
}));
vi.doMock("../pi-embedded-helpers.js", () => ({
formatBillingErrorMessage: vi.fn(() => ""),
classifyFailoverReason: mockedClassifyFailoverReason,
extractObservedOverflowTokenCount: mockedExtractObservedOverflowTokenCount,
formatAssistantErrorText: vi.fn(() => ""),
isAuthAssistantError: vi.fn(() => false),
isBillingAssistantError: vi.fn(() => false),
isCompactionFailureError: mockedIsCompactionFailureError,
isLikelyContextOverflowError: mockedIsLikelyContextOverflowError,
isFailoverAssistantError: vi.fn(() => false),
isFailoverErrorMessage: vi.fn(() => false),
parseImageSizeError: vi.fn(() => null),
parseImageDimensionError: vi.fn(() => null),
isRateLimitAssistantError: vi.fn(() => false),
isTimeoutErrorMessage: vi.fn(() => false),
pickFallbackThinkingLevel: mockedPickFallbackThinkingLevel,
}));
vi.doMock("./run/attempt.js", () => ({
runEmbeddedAttempt: mockedRunEmbeddedAttempt,
}));
vi.doMock("./model.js", () => ({
resolveModelAsync: vi.fn(async () => ({
model: {
id: "test-model",
provider: "anthropic",
contextWindow: 200000,
api: "messages",
},
error: null,
authStorage: {
setRuntimeApiKey: vi.fn(),
},
modelRegistry: {},
})),
}));
vi.doMock("../model-auth.js", () => ({
applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model),
ensureAuthProfileStore: vi.fn(() => ({})),
getApiKeyForModel: vi.fn(async () => ({
apiKey: "test-key",
profileId: "test-profile",
source: "test",
})),
resolveAuthProfileOrder: vi.fn(() => []),
}));
vi.doMock("../models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("../context-window-guard.js", () => ({
CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000,
CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000,
evaluateContextWindowGuard: vi.fn(() => ({
shouldWarn: false,
shouldBlock: false,
tokens: 200000,
source: "model",
})),
resolveContextWindowInfo: vi.fn(() => ({
tokens: 200000,
source: "model",
})),
}));
vi.doMock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()),
}));
vi.doMock("../../utils/message-channel.js", () => ({
isMarkdownCapableMessageChannel: vi.fn(() => true),
}));
vi.doMock("../agent-paths.js", () => ({
resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"),
}));
vi.doMock("../defaults.js", () => ({
DEFAULT_CONTEXT_TOKENS: 200000,
DEFAULT_MODEL: "test-model",
DEFAULT_PROVIDER: "anthropic",
}));
vi.doMock("../failover-error.js", () => ({
FailoverError: class extends Error {},
coerceToFailoverError: mockedCoerceToFailoverError,
describeFailoverError: mockedDescribeFailoverError,
resolveFailoverStatus: mockedResolveFailoverStatus,
}));
vi.doMock("./lanes.js", () => ({
resolveSessionLane: vi.fn(() => "session-lane"),
resolveGlobalLane: vi.fn(() => "global-lane"),
}));
vi.doMock("./logger.js", () => ({
log: mockedLog,
}));
vi.doMock("./run/payloads.js", () => ({
buildEmbeddedRunPayloads: vi.fn(() => []),
}));
vi.doMock("./tool-result-truncation.js", () => ({
truncateOversizedToolResultsInSession: mockedTruncateOversizedToolResultsInSession,
sessionLikelyHasOversizedToolResults: mockedSessionLikelyHasOversizedToolResults,
}));
vi.doMock("./utils.js", () => ({
describeUnknownError: vi.fn((err: unknown) => {
if (err instanceof Error) {
return err.message;
}
return String(err);
}),
}));
const { runEmbeddedPiAgent } = await import("./run.js");
return { runEmbeddedPiAgent };
}

Some files were not shown because too many files have changed in this diff Show More