mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 09:12:13 +08:00
Compare commits
3 Commits
vincentkoc
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b058d4d85 | ||
|
|
5bcf331d5c | ||
|
|
e20528c930 |
@@ -40,7 +40,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
@@ -52,10 +51,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
|
||||
@@ -439,4 +439,4 @@ Planned features:
|
||||
|
||||
- [Multi-Agent Configuration](/tools/multi-agent-sandbox-tools)
|
||||
- [Routing Configuration](/channels/channel-routing)
|
||||
- [Session Management](/concepts/session)
|
||||
- [Session Management](/concepts/sessions)
|
||||
|
||||
@@ -13,28 +13,28 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
|
||||
## Supported channels
|
||||
|
||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
|
||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||
- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls.
|
||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||
- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately).
|
||||
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
|
||||
- [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups).
|
||||
- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls.
|
||||
- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately).
|
||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||
- [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups).
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||
- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
|
||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||
- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately).
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
||||
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
|
||||
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
10
docs/concepts/sessions.md
Normal file
10
docs/concepts/sessions.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
summary: "Alias for session management docs"
|
||||
read_when:
|
||||
- You looked for docs/concepts/sessions.md; canonical doc lives in docs/concepts/session.md
|
||||
title: "Sessions"
|
||||
---
|
||||
|
||||
# Sessions
|
||||
|
||||
Canonical session management docs live in [Session management](/concepts/session).
|
||||
103
docs/docs.json
103
docs/docs.json
@@ -597,7 +597,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/sessions",
|
||||
"destination": "/concepts/session"
|
||||
"destination": "/concepts/sessions"
|
||||
},
|
||||
{
|
||||
"source": "/setup",
|
||||
@@ -832,6 +832,7 @@
|
||||
"group": "First steps",
|
||||
"pages": [
|
||||
"start/getting-started",
|
||||
"start/quickstart",
|
||||
"start/onboarding-overview",
|
||||
"start/wizard",
|
||||
"start/onboarding"
|
||||
@@ -898,25 +899,25 @@
|
||||
{
|
||||
"group": "Messaging platforms",
|
||||
"pages": [
|
||||
"channels/bluebubbles",
|
||||
"channels/whatsapp",
|
||||
"channels/telegram",
|
||||
"channels/discord",
|
||||
"channels/irc",
|
||||
"channels/slack",
|
||||
"channels/feishu",
|
||||
"channels/googlechat",
|
||||
"channels/mattermost",
|
||||
"channels/signal",
|
||||
"channels/imessage",
|
||||
"channels/irc",
|
||||
"channels/bluebubbles",
|
||||
"channels/msteams",
|
||||
"channels/synology-chat",
|
||||
"channels/line",
|
||||
"channels/matrix",
|
||||
"channels/mattermost",
|
||||
"channels/msteams",
|
||||
"channels/nextcloud-talk",
|
||||
"channels/nostr",
|
||||
"channels/signal",
|
||||
"channels/synology-chat",
|
||||
"channels/slack",
|
||||
"channels/telegram",
|
||||
"channels/tlon",
|
||||
"channels/twitch",
|
||||
"channels/whatsapp",
|
||||
"channels/zalo",
|
||||
"channels/zalouser"
|
||||
]
|
||||
@@ -959,6 +960,7 @@
|
||||
"group": "Sessions and memory",
|
||||
"pages": [
|
||||
"concepts/session",
|
||||
"concepts/sessions",
|
||||
"concepts/session-pruning",
|
||||
"concepts/session-tool",
|
||||
"concepts/memory",
|
||||
@@ -990,20 +992,20 @@
|
||||
{
|
||||
"group": "Built-in tools",
|
||||
"pages": [
|
||||
"tools/apply-patch",
|
||||
"brave-search",
|
||||
"perplexity",
|
||||
"tools/lobster",
|
||||
"tools/llm-task",
|
||||
"tools/diffs",
|
||||
"tools/elevated",
|
||||
"tools/exec",
|
||||
"tools/exec-approvals",
|
||||
"tools/firecrawl",
|
||||
"tools/llm-task",
|
||||
"tools/lobster",
|
||||
"tools/loop-detection",
|
||||
"tools/reactions",
|
||||
"tools/web",
|
||||
"tools/apply-patch",
|
||||
"tools/elevated",
|
||||
"tools/thinking",
|
||||
"tools/web"
|
||||
"tools/reactions"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1095,7 +1097,8 @@
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
"providers/anthropic",
|
||||
"providers/bedrock",
|
||||
"providers/openai",
|
||||
"providers/openrouter",
|
||||
"providers/cloudflare-ai-gateway",
|
||||
"providers/claude-max-api-proxy",
|
||||
"providers/deepgram",
|
||||
@@ -1103,24 +1106,23 @@
|
||||
"providers/huggingface",
|
||||
"providers/kilocode",
|
||||
"providers/litellm",
|
||||
"providers/glm",
|
||||
"providers/minimax",
|
||||
"providers/bedrock",
|
||||
"providers/vercel-ai-gateway",
|
||||
"providers/moonshot",
|
||||
"providers/mistral",
|
||||
"providers/minimax",
|
||||
"providers/nvidia",
|
||||
"providers/ollama",
|
||||
"providers/openai",
|
||||
"providers/opencode",
|
||||
"providers/openrouter",
|
||||
"providers/qianfan",
|
||||
"providers/qwen",
|
||||
"providers/synthetic",
|
||||
"providers/together",
|
||||
"providers/vercel-ai-gateway",
|
||||
"providers/venice",
|
||||
"providers/vllm",
|
||||
"providers/xiaomi",
|
||||
"providers/zai"
|
||||
"providers/glm",
|
||||
"providers/zai",
|
||||
"providers/synthetic",
|
||||
"providers/qianfan"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1430,6 +1432,7 @@
|
||||
"group": "第一步",
|
||||
"pages": [
|
||||
"zh-CN/start/getting-started",
|
||||
"zh-CN/start/quickstart",
|
||||
"zh-CN/start/wizard",
|
||||
"zh-CN/start/onboarding"
|
||||
]
|
||||
@@ -1494,24 +1497,24 @@
|
||||
{
|
||||
"group": "消息平台",
|
||||
"pages": [
|
||||
"zh-CN/channels/bluebubbles",
|
||||
"zh-CN/channels/discord",
|
||||
"zh-CN/channels/feishu",
|
||||
"zh-CN/channels/whatsapp",
|
||||
"zh-CN/channels/telegram",
|
||||
"zh-CN/channels/grammy",
|
||||
"zh-CN/channels/discord",
|
||||
"zh-CN/channels/slack",
|
||||
"zh-CN/channels/feishu",
|
||||
"zh-CN/channels/googlechat",
|
||||
"zh-CN/channels/mattermost",
|
||||
"zh-CN/channels/signal",
|
||||
"zh-CN/channels/imessage",
|
||||
"zh-CN/channels/bluebubbles",
|
||||
"zh-CN/channels/nextcloud-talk",
|
||||
"zh-CN/channels/msteams",
|
||||
"zh-CN/channels/line",
|
||||
"zh-CN/channels/matrix",
|
||||
"zh-CN/channels/mattermost",
|
||||
"zh-CN/channels/msteams",
|
||||
"zh-CN/channels/nextcloud-talk",
|
||||
"zh-CN/channels/nostr",
|
||||
"zh-CN/channels/signal",
|
||||
"zh-CN/channels/slack",
|
||||
"zh-CN/channels/telegram",
|
||||
"zh-CN/channels/tlon",
|
||||
"zh-CN/channels/twitch",
|
||||
"zh-CN/channels/whatsapp",
|
||||
"zh-CN/channels/zalo",
|
||||
"zh-CN/channels/zalouser"
|
||||
]
|
||||
@@ -1554,6 +1557,7 @@
|
||||
"group": "会话与记忆",
|
||||
"pages": [
|
||||
"zh-CN/concepts/session",
|
||||
"zh-CN/concepts/sessions",
|
||||
"zh-CN/concepts/session-pruning",
|
||||
"zh-CN/concepts/session-tool",
|
||||
"zh-CN/concepts/memory",
|
||||
@@ -1585,19 +1589,18 @@
|
||||
{
|
||||
"group": "内置工具",
|
||||
"pages": [
|
||||
"zh-CN/tools/apply-patch",
|
||||
"zh-CN/brave-search",
|
||||
"zh-CN/perplexity",
|
||||
"zh-CN/tools/diffs",
|
||||
"zh-CN/tools/elevated",
|
||||
"zh-CN/tools/lobster",
|
||||
"zh-CN/tools/llm-task",
|
||||
"zh-CN/tools/exec",
|
||||
"zh-CN/tools/exec-approvals",
|
||||
"zh-CN/tools/firecrawl",
|
||||
"zh-CN/tools/llm-task",
|
||||
"zh-CN/tools/lobster",
|
||||
"zh-CN/tools/reactions",
|
||||
"zh-CN/tools/web",
|
||||
"zh-CN/tools/apply-patch",
|
||||
"zh-CN/tools/elevated",
|
||||
"zh-CN/tools/thinking",
|
||||
"zh-CN/tools/web"
|
||||
"zh-CN/tools/reactions"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1687,24 +1690,24 @@
|
||||
"group": "提供商",
|
||||
"pages": [
|
||||
"zh-CN/providers/anthropic",
|
||||
"zh-CN/providers/openai",
|
||||
"zh-CN/providers/openrouter",
|
||||
"zh-CN/providers/bedrock",
|
||||
"zh-CN/providers/vercel-ai-gateway",
|
||||
"zh-CN/providers/claude-max-api-proxy",
|
||||
"zh-CN/providers/deepgram",
|
||||
"zh-CN/providers/github-copilot",
|
||||
"zh-CN/providers/glm",
|
||||
"zh-CN/providers/moonshot",
|
||||
"zh-CN/providers/minimax",
|
||||
"zh-CN/providers/opencode",
|
||||
"zh-CN/providers/ollama",
|
||||
"zh-CN/providers/openai",
|
||||
"zh-CN/providers/openrouter",
|
||||
"zh-CN/providers/qianfan",
|
||||
"zh-CN/providers/opencode",
|
||||
"zh-CN/providers/qwen",
|
||||
"zh-CN/providers/synthetic",
|
||||
"zh-CN/providers/venice",
|
||||
"zh-CN/providers/vercel-ai-gateway",
|
||||
"zh-CN/providers/xiaomi",
|
||||
"zh-CN/providers/zai"
|
||||
"zh-CN/providers/glm",
|
||||
"zh-CN/providers/zai",
|
||||
"zh-CN/providers/synthetic",
|
||||
"zh-CN/providers/qianfan"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -129,16 +129,6 @@ other runtimes), either bake a custom image or install via
|
||||
`sandbox.docker.setupCommand` (requires network egress + writable root +
|
||||
root user).
|
||||
|
||||
If you want a more functional sandbox image with common tooling (for example
|
||||
`curl`, `jq`, `nodejs`, `python3`, `git`), build:
|
||||
|
||||
```bash
|
||||
scripts/sandbox-common-setup.sh
|
||||
```
|
||||
|
||||
Then set `agents.defaults.sandbox.docker.image` to
|
||||
`openclaw-sandbox-common:bookworm-slim`.
|
||||
|
||||
Sandboxed browser image:
|
||||
|
||||
```bash
|
||||
@@ -157,11 +147,6 @@ Security defaults:
|
||||
Docker installs and the containerized gateway live here:
|
||||
[Docker](/install/docker)
|
||||
|
||||
For Docker gateway deployments, `docker-setup.sh` can bootstrap sandbox config.
|
||||
Set `OPENCLAW_SANDBOX=1` (or `true`/`yes`/`on`) to enable that path. You can
|
||||
override socket location with `OPENCLAW_DOCKER_SOCKET`. Full setup and env
|
||||
reference: [Docker](/install/docker#enable-agent-sandbox-for-docker-gateway-opt-in).
|
||||
|
||||
## setupCommand (one-time container setup)
|
||||
|
||||
`setupCommand` runs **once** after the sandbox container is created (not on every run).
|
||||
|
||||
@@ -35,29 +35,28 @@ See [Venice AI](/providers/venice).
|
||||
|
||||
## Provider docs
|
||||
|
||||
- [Amazon Bedrock](/providers/bedrock)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- [GLM models](/providers/glm)
|
||||
- [Hugging Face (Inference)](/providers/huggingface)
|
||||
- [Kilocode](/providers/kilocode)
|
||||
- [LiteLLM (unified gateway)](/providers/litellm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Mistral](/providers/mistral)
|
||||
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
|
||||
- [NVIDIA](/providers/nvidia)
|
||||
- [Ollama (local models)](/providers/ollama)
|
||||
- [OpenAI (API + Codex)](/providers/openai)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [Qwen (OAuth)](/providers/qwen)
|
||||
- [Together AI](/providers/together)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [LiteLLM (unified gateway)](/providers/litellm)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Venice (Venice AI, privacy-focused)](/providers/venice)
|
||||
- [vLLM (local models)](/providers/vllm)
|
||||
- [Xiaomi](/providers/xiaomi)
|
||||
- [Together AI](/providers/together)
|
||||
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
|
||||
- [Mistral](/providers/mistral)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [Amazon Bedrock](/providers/bedrock)
|
||||
- [Z.AI](/providers/zai)
|
||||
- [Xiaomi](/providers/xiaomi)
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Venice (Venice AI, privacy-focused)](/providers/venice)
|
||||
- [Hugging Face (Inference)](/providers/huggingface)
|
||||
- [Ollama (local models)](/providers/ollama)
|
||||
- [vLLM (local models)](/providers/vllm)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
- [NVIDIA](/providers/nvidia)
|
||||
|
||||
## Transcription providers
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Multi-agent routing](/concepts/multi-agent)
|
||||
- [Compaction](/concepts/compaction)
|
||||
- [Sessions](/concepts/session)
|
||||
- [Sessions (alias)](/concepts/sessions)
|
||||
- [Session pruning](/concepts/session-pruning)
|
||||
- [Session tools](/concepts/session-tool)
|
||||
- [Queue](/concepts/queue)
|
||||
|
||||
@@ -446,4 +446,4 @@ interface OpenClawConfig {
|
||||
|
||||
- [多智能体配置](/tools/multi-agent-sandbox-tools)
|
||||
- [路由配置](/channels/channel-routing)
|
||||
- [会话管理](/concepts/session)
|
||||
- [会话管理](/concepts/sessions)
|
||||
|
||||
@@ -20,26 +20,26 @@ OpenClaw 可以在你已经使用的任何聊天应用上与你交流。每个
|
||||
|
||||
## 支持的渠道
|
||||
|
||||
- [BlueBubbles](/channels/bluebubbles) — **推荐用于 iMessage**;使用 BlueBubbles macOS 服务器 REST API,功能完整(编辑、撤回、特效、回应、群组管理——编辑功能在 macOS 26 Tahoe 上目前不可用)。
|
||||
- [WhatsApp](/channels/whatsapp) — 最受欢迎;使用 Baileys,需要二维码配对。
|
||||
- [Telegram](/channels/telegram) — 通过 grammY 使用 Bot API;支持群组。
|
||||
- [Discord](/channels/discord) — Discord Bot API + Gateway;支持服务器、频道和私信。
|
||||
- [Slack](/channels/slack) — Bolt SDK;工作区应用。
|
||||
- [飞书](/channels/feishu) — 飞书(Lark)机器人(插件,需单独安装)。
|
||||
- [Google Chat](/channels/googlechat) — 通过 HTTP webhook 的 Google Chat API 应用。
|
||||
- [iMessage(旧版)](/channels/imessage) — 通过 imsg CLI 的旧版 macOS 集成(已弃用,新设置请使用 BlueBubbles)。
|
||||
- [LINE](/channels/line) — LINE Messaging API 机器人(插件,需单独安装)。
|
||||
- [Matrix](/channels/matrix) — Matrix 协议(插件,需单独安装)。
|
||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket;频道、群组、私信(插件,需单独安装)。
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework;企业支持(插件,需单独安装)。
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — 通过 Nextcloud Talk 的自托管聊天(插件,需单独安装)。
|
||||
- [Nostr](/channels/nostr) — 通过 NIP-04 的去中心化私信(插件,需单独安装)。
|
||||
- [Signal](/channels/signal) — signal-cli;注重隐私。
|
||||
- [Slack](/channels/slack) — Bolt SDK;工作区应用。
|
||||
- [Telegram](/channels/telegram) — 通过 grammY 使用 Bot API;支持群组。
|
||||
- [BlueBubbles](/channels/bluebubbles) — **推荐用于 iMessage**;使用 BlueBubbles macOS 服务器 REST API,功能完整(编辑、撤回、特效、回应、群组管理——编辑功能在 macOS 26 Tahoe 上目前不可用)。
|
||||
- [iMessage(旧版)](/channels/imessage) — 通过 imsg CLI 的旧版 macOS 集成(已弃用,新设置请使用 BlueBubbles)。
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework;企业支持(插件,需单独安装)。
|
||||
- [LINE](/channels/line) — LINE Messaging API 机器人(插件,需单独安装)。
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — 通过 Nextcloud Talk 的自托管聊天(插件,需单独安装)。
|
||||
- [Matrix](/channels/matrix) — Matrix 协议(插件,需单独安装)。
|
||||
- [Nostr](/channels/nostr) — 通过 NIP-04 的去中心化私信(插件,需单独安装)。
|
||||
- [Tlon](/channels/tlon) — 基于 Urbit 的消息应用(插件,需单独安装)。
|
||||
- [Twitch](/channels/twitch) — 通过 IRC 连接的 Twitch 聊天(插件,需单独安装)。
|
||||
- [WebChat](/web/webchat) — 基于 WebSocket 的 Gateway 网关 WebChat 界面。
|
||||
- [WhatsApp](/channels/whatsapp) — 最受欢迎;使用 Baileys,需要二维码配对。
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API;越南流行的消息应用(插件,需单独安装)。
|
||||
- [Zalo Personal](/channels/zalouser) — 通过二维码登录的 Zalo 个人账号(插件,需单独安装)。
|
||||
- [WebChat](/web/webchat) — 基于 WebSocket 的 Gateway 网关 WebChat 界面。
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
17
docs/zh-CN/concepts/sessions.md
Normal file
17
docs/zh-CN/concepts/sessions.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
read_when:
|
||||
- 你查找了 docs/sessions.md;规范文档位于 docs/session.md
|
||||
summary: 会话管理文档的别名
|
||||
title: 会话
|
||||
x-i18n:
|
||||
generated_at: "2026-02-01T20:23:55Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: 7f1e39c3c07b9bb5cdcda361399cf1ce1226ebae3a797d8f93e734aa6a4d00e2
|
||||
source_path: concepts/sessions.md
|
||||
workflow: 14
|
||||
---
|
||||
|
||||
# 会话
|
||||
|
||||
规范的会话管理文档位于[会话管理](/concepts/session)。
|
||||
@@ -41,19 +41,20 @@ Venice 是我们推荐的 Venice AI 设置,用于隐私优先的推理,并
|
||||
|
||||
## 提供商文档
|
||||
|
||||
- [Amazon Bedrock](/providers/bedrock)
|
||||
- [OpenAI(API + Codex)](/providers/openai)
|
||||
- [Anthropic(API + Claude Code CLI)](/providers/anthropic)
|
||||
- [Qwen(OAuth)](/providers/qwen)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Moonshot AI(Kimi + Kimi Coding)](/providers/moonshot)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [Amazon Bedrock](/providers/bedrock)
|
||||
- [Z.AI](/providers/zai)
|
||||
- [Xiaomi](/providers/xiaomi)
|
||||
- [GLM 模型](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Moonshot AI(Kimi + Kimi Coding)](/providers/moonshot)
|
||||
- [Ollama(本地模型)](/providers/ollama)
|
||||
- [OpenAI(API + Codex)](/providers/openai)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Qwen(OAuth)](/providers/qwen)
|
||||
- [Venice(Venice AI,注重隐私)](/providers/venice)
|
||||
- [Xiaomi](/providers/xiaomi)
|
||||
- [Z.AI](/providers/zai)
|
||||
- [Ollama(本地模型)](/providers/ollama)
|
||||
|
||||
## 转录提供商
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ x-i18n:
|
||||
- [多智能体路由](/concepts/multi-agent)
|
||||
- [压缩](/concepts/compaction)
|
||||
- [会话](/concepts/session)
|
||||
- [会话(别名)](/concepts/sessions)
|
||||
- [会话修剪](/concepts/session-pruning)
|
||||
- [会话工具](/concepts/session-tool)
|
||||
- [队列](/concepts/queue)
|
||||
|
||||
@@ -2,28 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
|
||||
|
||||
function createDefaultSpawnConfig(): OpenClawConfig {
|
||||
return {
|
||||
acp: {
|
||||
enabled: true,
|
||||
backend: "acpx",
|
||||
allowedAgents: ["codex"],
|
||||
},
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const callGatewayMock = vi.fn();
|
||||
const sessionBindingCapabilitiesMock = vi.fn();
|
||||
@@ -34,7 +12,25 @@ const hoisted = vi.hoisted(() => {
|
||||
const closeSessionMock = vi.fn();
|
||||
const initializeSessionMock = vi.fn();
|
||||
const state = {
|
||||
cfg: createDefaultSpawnConfig(),
|
||||
cfg: {
|
||||
acp: {
|
||||
enabled: true,
|
||||
backend: "acpx",
|
||||
allowedAgents: ["codex"],
|
||||
},
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
};
|
||||
return {
|
||||
callGatewayMock,
|
||||
@@ -49,27 +45,6 @@ const hoisted = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
function buildSessionBindingServiceMock() {
|
||||
return {
|
||||
touch: vi.fn(),
|
||||
bind(input: unknown) {
|
||||
return hoisted.sessionBindingBindMock(input);
|
||||
},
|
||||
unbind(input: unknown) {
|
||||
return hoisted.sessionBindingUnbindMock(input);
|
||||
},
|
||||
getCapabilities(params: unknown) {
|
||||
return hoisted.sessionBindingCapabilitiesMock(params);
|
||||
},
|
||||
resolveByConversation(ref: unknown) {
|
||||
return hoisted.sessionBindingResolveByConversationMock(ref);
|
||||
},
|
||||
listBySession(targetSessionKey: string) {
|
||||
return hoisted.sessionBindingListBySessionMock(targetSessionKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
@@ -96,21 +71,20 @@ vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) =
|
||||
await importOriginal<typeof import("../infra/outbound/session-binding-service.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getSessionBindingService: () => buildSessionBindingServiceMock(),
|
||||
getSessionBindingService: () => ({
|
||||
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
|
||||
getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
|
||||
listBySession: (targetSessionKey: string) =>
|
||||
hoisted.sessionBindingListBySessionMock(targetSessionKey),
|
||||
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
|
||||
touch: vi.fn(),
|
||||
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { spawnAcpDirect } = await import("./acp-spawn.js");
|
||||
|
||||
function createSessionBindingCapabilities() {
|
||||
return {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"] as const,
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
return {
|
||||
bindingId: "default:child-thread",
|
||||
@@ -132,21 +106,27 @@ function createSessionBinding(overrides?: Partial<SessionBindingRecord>): Sessio
|
||||
};
|
||||
}
|
||||
|
||||
function expectResolvedIntroTextInBindMetadata(): void {
|
||||
const callWithMetadata = hoisted.sessionBindingBindMock.mock.calls.find(
|
||||
(call: unknown[]) =>
|
||||
typeof (call[0] as { metadata?: { introText?: unknown } } | undefined)?.metadata
|
||||
?.introText === "string",
|
||||
);
|
||||
const introText =
|
||||
(callWithMetadata?.[0] as { metadata?: { introText?: string } } | undefined)?.metadata
|
||||
?.introText ?? "";
|
||||
expect(introText.includes("session ids: pending (available after the first reply)")).toBe(false);
|
||||
}
|
||||
|
||||
describe("spawnAcpDirect", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.state.cfg = createDefaultSpawnConfig();
|
||||
hoisted.state.cfg = {
|
||||
acp: {
|
||||
enabled: true,
|
||||
backend: "acpx",
|
||||
allowedAgents: ["codex"],
|
||||
},
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
|
||||
const args = argsUnknown as { method?: string };
|
||||
@@ -206,9 +186,12 @@ describe("spawnAcpDirect", () => {
|
||||
};
|
||||
});
|
||||
|
||||
hoisted.sessionBindingCapabilitiesMock
|
||||
.mockReset()
|
||||
.mockReturnValue(createSessionBindingCapabilities());
|
||||
hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
});
|
||||
hoisted.sessionBindingBindMock
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
@@ -265,7 +248,15 @@ describe("spawnAcpDirect", () => {
|
||||
placement: "child",
|
||||
}),
|
||||
);
|
||||
expectResolvedIntroTextInBindMetadata();
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
introText: expect.not.stringContaining(
|
||||
"session ids: pending (available after the first reply)",
|
||||
),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
|
||||
@@ -1,28 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCompactionSummarizationInstructions } from "./compaction.js";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import * as piCodingAgent from "@mariozechner/pi-coding-agent";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildCompactionSummarizationInstructions, summarizeInStages } from "./compaction.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof piCodingAgent>();
|
||||
return {
|
||||
...actual,
|
||||
generateSummary: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
|
||||
|
||||
function makeMessage(index: number, size = 1200): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: `m${index}-${"x".repeat(size)}`,
|
||||
timestamp: index,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compaction identifier policy", () => {
|
||||
it("defaults to strict identifier preservation", () => {
|
||||
const built = buildCompactionSummarizationInstructions();
|
||||
expect(built).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(built).toContain("UUIDs");
|
||||
const testModel = {
|
||||
provider: "anthropic",
|
||||
model: "claude-3-opus",
|
||||
contextWindow: 200_000,
|
||||
} as unknown as NonNullable<ExtensionContext["model"]>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGenerateSummary.mockReset();
|
||||
mockGenerateSummary.mockResolvedValue("summary");
|
||||
});
|
||||
|
||||
it("can disable identifier preservation with off policy", () => {
|
||||
const built = buildCompactionSummarizationInstructions(undefined, {
|
||||
identifierPolicy: "off",
|
||||
it("defaults to strict identifier preservation", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
});
|
||||
expect(built).toBeUndefined();
|
||||
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(firstCall?.[5]).toContain("UUIDs");
|
||||
});
|
||||
|
||||
it("supports custom identifier instructions", () => {
|
||||
const built = buildCompactionSummarizationInstructions(undefined, {
|
||||
identifierPolicy: "custom",
|
||||
identifierInstructions: "Keep ticket IDs unchanged.",
|
||||
it("can disable identifier preservation with off policy", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
summarizationInstructions: { identifierPolicy: "off" },
|
||||
});
|
||||
|
||||
expect(built).toContain("Keep ticket IDs unchanged.");
|
||||
expect(built).not.toContain("Preserve all opaque identifiers exactly as written");
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports custom identifier instructions", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
summarizationInstructions: {
|
||||
identifierPolicy: "custom",
|
||||
identifierInstructions: "Keep ticket IDs unchanged.",
|
||||
},
|
||||
});
|
||||
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toContain("Keep ticket IDs unchanged.");
|
||||
expect(firstCall?.[5]).not.toContain("Preserve all opaque identifiers exactly as written");
|
||||
});
|
||||
|
||||
it("falls back to strict text when custom policy is missing instructions", () => {
|
||||
@@ -33,10 +94,24 @@ describe("compaction identifier policy", () => {
|
||||
expect(built).toContain("Preserve all opaque identifiers exactly as written");
|
||||
});
|
||||
|
||||
it("keeps custom focus text when identifier policy is off", () => {
|
||||
const built = buildCompactionSummarizationInstructions("Track release blockers.", {
|
||||
identifierPolicy: "off",
|
||||
it("avoids duplicate additional-focus headers in split+merge path", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 1000,
|
||||
contextWindow: 200_000,
|
||||
parts: 2,
|
||||
minMessagesForSplit: 4,
|
||||
customInstructions: "Prioritize customer-visible regressions.",
|
||||
});
|
||||
expect(built).toBe("Additional focus:\nTrack release blockers.");
|
||||
|
||||
const mergedCall = mockGenerateSummary.mock.calls.at(-1);
|
||||
const instructions = mergedCall?.[5] ?? "";
|
||||
expect(instructions).toContain("Merge these partial summaries into a single cohesive summary.");
|
||||
expect(instructions).toContain("Prioritize customer-visible regressions.");
|
||||
expect((instructions.match(/Additional focus:/g) ?? []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
||||
});
|
||||
|
||||
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
|
||||
type SummarizeInStagesInput = Parameters<typeof summarizeInStages>[0];
|
||||
|
||||
function makeMessage(index: number, size = 1200): AgentMessage {
|
||||
return {
|
||||
@@ -29,63 +28,58 @@ describe("compaction identifier-preservation instructions", () => {
|
||||
model: "claude-3-opus",
|
||||
contextWindow: 200_000,
|
||||
} as unknown as NonNullable<ExtensionContext["model"]>;
|
||||
const summarizeBase: Omit<SummarizeInStagesInput, "messages"> = {
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGenerateSummary.mockReset();
|
||||
mockGenerateSummary.mockResolvedValue("summary");
|
||||
});
|
||||
|
||||
async function runSummary(
|
||||
messageCount: number,
|
||||
overrides: Partial<Omit<SummarizeInStagesInput, "messages">> = {},
|
||||
) {
|
||||
await summarizeInStages({
|
||||
...summarizeBase,
|
||||
...overrides,
|
||||
signal: new AbortController().signal,
|
||||
messages: Array.from({ length: messageCount }, (_unused, index) => makeMessage(index + 1)),
|
||||
});
|
||||
}
|
||||
|
||||
function firstSummaryInstructions() {
|
||||
return mockGenerateSummary.mock.calls[0]?.[5];
|
||||
}
|
||||
|
||||
it("injects identifier-preservation guidance even without custom instructions", async () => {
|
||||
await runSummary(2);
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
});
|
||||
|
||||
expect(mockGenerateSummary).toHaveBeenCalled();
|
||||
expect(firstSummaryInstructions()).toContain(
|
||||
"Preserve all opaque identifiers exactly as written",
|
||||
);
|
||||
expect(firstSummaryInstructions()).toContain("UUIDs");
|
||||
expect(firstSummaryInstructions()).toContain("IPs");
|
||||
expect(firstSummaryInstructions()).toContain("ports");
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(firstCall?.[5]).toContain("UUIDs");
|
||||
expect(firstCall?.[5]).toContain("IPs");
|
||||
expect(firstCall?.[5]).toContain("ports");
|
||||
});
|
||||
|
||||
it("keeps identifier-preservation guidance when custom instructions are provided", async () => {
|
||||
await runSummary(2, {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
customInstructions: "Focus on release-impacting bugs.",
|
||||
});
|
||||
|
||||
expect(firstSummaryInstructions()).toContain(
|
||||
"Preserve all opaque identifiers exactly as written",
|
||||
);
|
||||
expect(firstSummaryInstructions()).toContain("Additional focus:");
|
||||
expect(firstSummaryInstructions()).toContain("Focus on release-impacting bugs.");
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(firstCall?.[5]).toContain("Additional focus:");
|
||||
expect(firstCall?.[5]).toContain("Focus on release-impacting bugs.");
|
||||
});
|
||||
|
||||
it("applies identifier-preservation guidance on staged split + merge summarization", async () => {
|
||||
await runSummary(4, {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 1000,
|
||||
contextWindow: 200_000,
|
||||
parts: 2,
|
||||
minMessagesForSplit: 4,
|
||||
});
|
||||
@@ -97,8 +91,14 @@ describe("compaction identifier-preservation instructions", () => {
|
||||
});
|
||||
|
||||
it("avoids duplicate additional-focus headers in split+merge path", async () => {
|
||||
await runSummary(4, {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 1000,
|
||||
contextWindow: 200_000,
|
||||
parts: 2,
|
||||
minMessagesForSplit: 4,
|
||||
customInstructions: "Prioritize customer-visible regressions.",
|
||||
|
||||
@@ -8,25 +8,6 @@ import {
|
||||
type PiSdkModule,
|
||||
} from "./model-catalog.test-harness.js";
|
||||
|
||||
function mockPiDiscoveryModels(models: unknown[]) {
|
||||
__setModelCatalogImportForTest(
|
||||
async () =>
|
||||
({
|
||||
discoverAuthStorage: () => ({}),
|
||||
AuthStorage: class {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return models;
|
||||
}
|
||||
},
|
||||
}) as unknown as PiSdkModule,
|
||||
);
|
||||
}
|
||||
|
||||
function mockSingleOpenAiCatalogModel() {
|
||||
mockPiDiscoveryModels([{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]);
|
||||
}
|
||||
|
||||
describe("loadModelCatalog", () => {
|
||||
installModelCatalogTestHooks();
|
||||
|
||||
@@ -86,21 +67,32 @@ describe("loadModelCatalog", () => {
|
||||
});
|
||||
|
||||
it("adds openai-codex/gpt-5.3-codex-spark when base gpt-5.3-codex exists", async () => {
|
||||
mockPiDiscoveryModels([
|
||||
{
|
||||
id: "gpt-5.3-codex",
|
||||
provider: "openai-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
reasoning: true,
|
||||
contextWindow: 200000,
|
||||
input: ["text"],
|
||||
},
|
||||
{
|
||||
id: "gpt-5.2-codex",
|
||||
provider: "openai-codex",
|
||||
name: "GPT-5.2 Codex",
|
||||
},
|
||||
]);
|
||||
__setModelCatalogImportForTest(
|
||||
async () =>
|
||||
({
|
||||
discoverAuthStorage: () => ({}),
|
||||
AuthStorage: class {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return [
|
||||
{
|
||||
id: "gpt-5.3-codex",
|
||||
provider: "openai-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
reasoning: true,
|
||||
contextWindow: 200000,
|
||||
input: ["text"],
|
||||
},
|
||||
{
|
||||
id: "gpt-5.2-codex",
|
||||
provider: "openai-codex",
|
||||
name: "GPT-5.2 Codex",
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
}) as unknown as PiSdkModule,
|
||||
);
|
||||
|
||||
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
|
||||
expect(result).toContainEqual(
|
||||
@@ -115,7 +107,18 @@ describe("loadModelCatalog", () => {
|
||||
});
|
||||
|
||||
it("merges configured models for opted-in non-pi-native providers", async () => {
|
||||
mockSingleOpenAiCatalogModel();
|
||||
__setModelCatalogImportForTest(
|
||||
async () =>
|
||||
({
|
||||
discoverAuthStorage: () => ({}),
|
||||
AuthStorage: class {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }];
|
||||
}
|
||||
},
|
||||
}) as unknown as PiSdkModule,
|
||||
);
|
||||
|
||||
const result = await loadModelCatalog({
|
||||
config: {
|
||||
@@ -151,7 +154,18 @@ describe("loadModelCatalog", () => {
|
||||
});
|
||||
|
||||
it("does not merge configured models for providers that are not opted in", async () => {
|
||||
mockSingleOpenAiCatalogModel();
|
||||
__setModelCatalogImportForTest(
|
||||
async () =>
|
||||
({
|
||||
discoverAuthStorage: () => ({}),
|
||||
AuthStorage: class {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }];
|
||||
}
|
||||
},
|
||||
}) as unknown as PiSdkModule,
|
||||
);
|
||||
|
||||
const result = await loadModelCatalog({
|
||||
config: {
|
||||
@@ -183,13 +197,24 @@ describe("loadModelCatalog", () => {
|
||||
});
|
||||
|
||||
it("does not duplicate opted-in configured models already present in ModelRegistry", async () => {
|
||||
mockPiDiscoveryModels([
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
provider: "kilocode",
|
||||
name: "Claude Opus 4.6",
|
||||
},
|
||||
]);
|
||||
__setModelCatalogImportForTest(
|
||||
async () =>
|
||||
({
|
||||
discoverAuthStorage: () => ({}),
|
||||
AuthStorage: class {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
provider: "kilocode",
|
||||
name: "Claude Opus 4.6",
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
}) as unknown as PiSdkModule,
|
||||
);
|
||||
|
||||
const result = await loadModelCatalog({
|
||||
config: {
|
||||
|
||||
@@ -15,40 +15,6 @@ import {
|
||||
resolveModelRefFromString,
|
||||
} from "./model-selection.js";
|
||||
|
||||
const EXPLICIT_ALLOWLIST_CONFIG = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.2" },
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const BUNDLED_ALLOWLIST_CATALOG = [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
|
||||
];
|
||||
|
||||
const ANTHROPIC_OPUS_CATALOG = [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
reasoning: true,
|
||||
},
|
||||
];
|
||||
|
||||
function resolveAnthropicOpusThinking(cfg: OpenClawConfig) {
|
||||
return resolveThinkingDefault({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-6",
|
||||
catalog: ANTHROPIC_OPUS_CATALOG,
|
||||
});
|
||||
}
|
||||
|
||||
describe("model-selection", () => {
|
||||
describe("normalizeProviderId", () => {
|
||||
it("should normalize provider names", () => {
|
||||
@@ -279,9 +245,25 @@ describe("model-selection", () => {
|
||||
|
||||
describe("buildAllowedModelSet", () => {
|
||||
it("keeps explicitly allowlisted models even when missing from bundled catalog", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.2" },
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const catalog = [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
|
||||
];
|
||||
|
||||
const result = buildAllowedModelSet({
|
||||
cfg: EXPLICIT_ALLOWLIST_CONFIG,
|
||||
catalog: BUNDLED_ALLOWLIST_CATALOG,
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
|
||||
@@ -295,9 +277,25 @@ describe("model-selection", () => {
|
||||
|
||||
describe("resolveAllowedModelRef", () => {
|
||||
it("accepts explicit allowlist refs absent from bundled catalog", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.2" },
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const catalog = [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
|
||||
];
|
||||
|
||||
const result = resolveAllowedModelRef({
|
||||
cfg: EXPLICIT_ALLOWLIST_CONFIG,
|
||||
catalog: BUNDLED_ALLOWLIST_CATALOG,
|
||||
cfg,
|
||||
catalog,
|
||||
raw: "anthropic/claude-sonnet-4-6",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-5.2",
|
||||
@@ -489,7 +487,21 @@ describe("model-selection", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveAnthropicOpusThinking(cfg)).toBe("high");
|
||||
expect(
|
||||
resolveThinkingDefault({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-6",
|
||||
catalog: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
reasoning: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("high");
|
||||
});
|
||||
|
||||
it("accepts per-model params.thinking=adaptive", () => {
|
||||
@@ -505,13 +517,41 @@ describe("model-selection", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive");
|
||||
expect(
|
||||
resolveThinkingDefault({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-6",
|
||||
catalog: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
reasoning: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("adaptive");
|
||||
});
|
||||
|
||||
it("defaults Anthropic Claude 4.6 models to adaptive", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive");
|
||||
expect(
|
||||
resolveThinkingDefault({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-6",
|
||||
catalog: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
reasoning: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("adaptive");
|
||||
|
||||
expect(
|
||||
resolveThinkingDefault({
|
||||
|
||||
@@ -14,98 +14,6 @@ import { readGeneratedModelsJson } from "./models-config.test-utils.js";
|
||||
|
||||
installModelsConfigTestHooks();
|
||||
|
||||
const MODELS_JSON_NAME = "models.json";
|
||||
|
||||
async function withEnvVar(name: string, value: string, run: () => Promise<void>) {
|
||||
const previous = process.env[name];
|
||||
process.env[name] = value;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAgentModelsJson(content: unknown): Promise<void> {
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, MODELS_JSON_NAME),
|
||||
JSON.stringify(content, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function createMergeConfigProvider() {
|
||||
return {
|
||||
baseUrl: "https://config.example/v1",
|
||||
apiKey: "CONFIG_KEY",
|
||||
api: "openai-responses" as const,
|
||||
models: [
|
||||
{
|
||||
id: "config-model",
|
||||
name: "Config model",
|
||||
input: ["text"] as Array<"text" | "image">,
|
||||
reasoning: false,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function runCustomProviderMergeTest(seedProvider: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
api: string;
|
||||
models: Array<{ id: string; name: string; input: string[] }>;
|
||||
}) {
|
||||
await writeAgentModelsJson({ providers: { custom: seedProvider } });
|
||||
await ensureOpenClawModelsJson({
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
custom: createMergeConfigProvider(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return readGeneratedModelsJson<{
|
||||
providers: Record<string, { apiKey?: string; baseUrl?: string }>;
|
||||
}>();
|
||||
}
|
||||
|
||||
function createMoonshotConfig(overrides: {
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
models: {
|
||||
providers: {
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: overrides.contextWindow,
|
||||
maxTokens: overrides.maxTokens,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("models-config", () => {
|
||||
it("keeps anthropic api defaults when model entries omit api", async () => {
|
||||
await withTempHome(async () => {
|
||||
@@ -138,7 +46,9 @@ describe("models-config", () => {
|
||||
|
||||
it("fills missing provider.apiKey from env var name when models exist", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withEnvVar("MINIMAX_API_KEY", "sk-minimax-test", async () => {
|
||||
const prevKey = process.env.MINIMAX_API_KEY;
|
||||
process.env.MINIMAX_API_KEY = "sk-minimax-test";
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
@@ -169,38 +79,55 @@ describe("models-config", () => {
|
||||
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
|
||||
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
|
||||
expect(ids).toContain("MiniMax-VL-01");
|
||||
});
|
||||
} finally {
|
||||
if (prevKey === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
it("merges providers by default", async () => {
|
||||
await withTempHome(async () => {
|
||||
await writeAgentModelsJson({
|
||||
providers: {
|
||||
existing: {
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: "EXISTING_KEY",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "existing-model",
|
||||
name: "Existing",
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
providers: {
|
||||
existing: {
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: "EXISTING_KEY",
|
||||
api: "openai-completions",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
models: [
|
||||
{
|
||||
id: "existing-model",
|
||||
name: "Existing",
|
||||
api: "openai-completions",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
}>();
|
||||
};
|
||||
|
||||
expect(parsed.providers.existing?.baseUrl).toBe("http://localhost:1234/v1");
|
||||
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
|
||||
@@ -209,12 +136,54 @@ describe("models-config", () => {
|
||||
|
||||
it("preserves non-empty agent apiKey/baseUrl for matching providers in merge mode", async () => {
|
||||
await withTempHome(async () => {
|
||||
const parsed = await runCustomProviderMergeTest({
|
||||
baseUrl: "https://agent.example/v1",
|
||||
apiKey: "AGENT_KEY",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://agent.example/v1",
|
||||
apiKey: "AGENT_KEY",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await ensureOpenClawModelsJson({
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://config.example/v1",
|
||||
apiKey: "CONFIG_KEY",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "config-model",
|
||||
name: "Config model",
|
||||
input: ["text"],
|
||||
reasoning: false,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
providers: Record<string, { apiKey?: string; baseUrl?: string }>;
|
||||
}>();
|
||||
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
|
||||
expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1");
|
||||
});
|
||||
@@ -222,12 +191,54 @@ describe("models-config", () => {
|
||||
|
||||
it("uses config apiKey/baseUrl when existing agent values are empty", async () => {
|
||||
await withTempHome(async () => {
|
||||
const parsed = await runCustomProviderMergeTest({
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await ensureOpenClawModelsJson({
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://config.example/v1",
|
||||
apiKey: "CONFIG_KEY",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "config-model",
|
||||
name: "Config model",
|
||||
input: ["text"],
|
||||
reasoning: false,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
providers: Record<string, { apiKey?: string; baseUrl?: string }>;
|
||||
}>();
|
||||
expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY");
|
||||
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
|
||||
});
|
||||
@@ -235,12 +246,36 @@ describe("models-config", () => {
|
||||
|
||||
it("refreshes stale explicit moonshot model capabilities from implicit catalog", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
|
||||
const cfg = createMoonshotConfig({ contextWindow: 1024, maxTokens: 256 });
|
||||
const prevKey = process.env.MOONSHOT_API_KEY;
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-test";
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1024,
|
||||
maxTokens: 256,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<
|
||||
string,
|
||||
{
|
||||
@@ -254,7 +289,7 @@ describe("models-config", () => {
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
}>();
|
||||
};
|
||||
const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5");
|
||||
expect(kimi?.input).toEqual(["text", "image"]);
|
||||
expect(kimi?.reasoning).toBe(false);
|
||||
@@ -263,14 +298,42 @@ describe("models-config", () => {
|
||||
// Preserve explicit user pricing overrides when refreshing capabilities.
|
||||
expect(kimi?.cost?.input).toBe(123);
|
||||
expect(kimi?.cost?.output).toBe(456);
|
||||
});
|
||||
} finally {
|
||||
if (prevKey === undefined) {
|
||||
delete process.env.MOONSHOT_API_KEY;
|
||||
} else {
|
||||
process.env.MOONSHOT_API_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
|
||||
const cfg = createMoonshotConfig({ contextWindow: 350000, maxTokens: 16384 });
|
||||
const prevKey = process.env.MOONSHOT_API_KEY;
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-test";
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 350000,
|
||||
maxTokens: 16384,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
@@ -288,7 +351,13 @@ describe("models-config", () => {
|
||||
const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5");
|
||||
expect(kimi?.contextWindow).toBe(350000);
|
||||
expect(kimi?.maxTokens).toBe(16384);
|
||||
});
|
||||
} finally {
|
||||
if (prevKey === undefined) {
|
||||
delete process.env.MOONSHOT_API_KEY;
|
||||
} else {
|
||||
process.env.MOONSHOT_API_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
installModelsConfigTestHooks,
|
||||
withModelsTempHome as withTempHome,
|
||||
} from "./models-config.e2e-harness.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
|
||||
|
||||
installModelsConfigTestHooks();
|
||||
|
||||
@@ -20,49 +22,23 @@ type ModelsJson = {
|
||||
providers: Record<string, { models?: ModelEntry[] }>;
|
||||
};
|
||||
|
||||
const MINIMAX_ENV_KEY = "MINIMAX_API_KEY";
|
||||
const MINIMAX_MODEL_ID = "MiniMax-M2.5";
|
||||
const MINIMAX_TEST_KEY = "sk-minimax-test";
|
||||
|
||||
const baseMinimaxProvider = {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
} as const;
|
||||
|
||||
async function withMinimaxApiKey(run: () => Promise<void>) {
|
||||
const prev = process.env[MINIMAX_ENV_KEY];
|
||||
process.env[MINIMAX_ENV_KEY] = MINIMAX_TEST_KEY;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env[MINIMAX_ENV_KEY];
|
||||
} else {
|
||||
process.env[MINIMAX_ENV_KEY] = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAndReadMinimaxModel(cfg: OpenClawConfig): Promise<ModelEntry | undefined> {
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
const parsed = await readGeneratedModelsJson<ModelsJson>();
|
||||
return parsed.providers.minimax?.models?.find((model) => model.id === MINIMAX_MODEL_ID);
|
||||
}
|
||||
|
||||
describe("models-config: explicit reasoning override", () => {
|
||||
it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.5)", async () => {
|
||||
// MiniMax-M2.5 has reasoning:true in the built-in catalog.
|
||||
// User explicitly sets reasoning:false to avoid message-ordering conflicts.
|
||||
await withTempHome(async () => {
|
||||
await withMinimaxApiKey(async () => {
|
||||
const prevKey = process.env.MINIMAX_API_KEY;
|
||||
process.env.MINIMAX_API_KEY = "sk-minimax-test";
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
...baseMinimaxProvider,
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: MINIMAX_MODEL_ID,
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false, // explicit override: user wants to disable reasoning
|
||||
input: ["text"],
|
||||
@@ -76,11 +52,21 @@ describe("models-config: explicit reasoning override", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const m25 = await generateAndReadMinimaxModel(cfg);
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as ModelsJson;
|
||||
const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5");
|
||||
expect(m25).toBeDefined();
|
||||
// Must honour the explicit false — built-in true must NOT win.
|
||||
expect(m25?.reasoning).toBe(false);
|
||||
});
|
||||
} finally {
|
||||
if (prevKey === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,10 +74,12 @@ describe("models-config: explicit reasoning override", () => {
|
||||
// When the user does not set reasoning at all, the built-in catalog value
|
||||
// (true for MiniMax-M2.5) should be used so the model works out of the box.
|
||||
await withTempHome(async () => {
|
||||
await withMinimaxApiKey(async () => {
|
||||
const prevKey = process.env.MINIMAX_API_KEY;
|
||||
process.env.MINIMAX_API_KEY = "sk-minimax-test";
|
||||
try {
|
||||
// Omit 'reasoning' to simulate a user config that doesn't set it.
|
||||
const modelWithoutReasoning = {
|
||||
id: MINIMAX_MODEL_ID,
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -102,7 +90,8 @@ describe("models-config: explicit reasoning override", () => {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
...baseMinimaxProvider,
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
// @ts-expect-error Intentional: emulate user config omitting reasoning.
|
||||
models: [modelWithoutReasoning],
|
||||
},
|
||||
@@ -110,11 +99,21 @@ describe("models-config: explicit reasoning override", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const m25 = await generateAndReadMinimaxModel(cfg);
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as ModelsJson;
|
||||
const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5");
|
||||
expect(m25).toBeDefined();
|
||||
// Built-in catalog has reasoning:true — should be applied as default.
|
||||
expect(m25?.reasoning).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
if (prevKey === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,14 +15,6 @@ import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
const NODE_ID = "mac-1";
|
||||
const BASE_RUN_INPUT = { action: "run", node: NODE_ID, command: ["echo", "hi"] } as const;
|
||||
const JPG_PAYLOAD = {
|
||||
format: "jpg",
|
||||
base64: "aGVsbG8=",
|
||||
width: 1,
|
||||
height: 1,
|
||||
} as const;
|
||||
|
||||
type GatewayCall = { method: string; params?: unknown };
|
||||
|
||||
function unexpectedGatewayMethod(method: unknown): never {
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
@@ -40,99 +32,24 @@ async function executeNodes(input: Record<string, unknown>) {
|
||||
return getNodesTool().execute("call1", input as never);
|
||||
}
|
||||
|
||||
type NodesToolResult = Awaited<ReturnType<typeof executeNodes>>;
|
||||
type GatewayMockResult = Record<string, unknown> | null | undefined;
|
||||
|
||||
function mockNodeList(commands?: string[]) {
|
||||
return {
|
||||
nodes: [{ nodeId: NODE_ID, ...(commands ? { commands } : {}) }],
|
||||
};
|
||||
}
|
||||
|
||||
function expectSingleImage(result: NodesToolResult, params?: { mimeType?: string }) {
|
||||
const images = (result.content ?? []).filter((block) => block.type === "image");
|
||||
expect(images).toHaveLength(1);
|
||||
if (params?.mimeType) {
|
||||
expect(images[0]?.mimeType).toBe(params.mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
function expectFirstTextContains(result: NodesToolResult, expectedText: string) {
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining(expectedText),
|
||||
});
|
||||
}
|
||||
|
||||
function setupNodeInvokeMock(params: {
|
||||
commands?: string[];
|
||||
onInvoke?: (invokeParams: unknown) => GatewayMockResult | Promise<GatewayMockResult>;
|
||||
invokePayload?: unknown;
|
||||
}) {
|
||||
callGateway.mockImplementation(async ({ method, params: invokeParams }: GatewayCall) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(params.commands);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
if (params.onInvoke) {
|
||||
return await params.onInvoke(invokeParams);
|
||||
}
|
||||
if (params.invokePayload !== undefined) {
|
||||
return { payload: params.invokePayload };
|
||||
}
|
||||
return { payload: {} };
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
}
|
||||
|
||||
function createSystemRunPreparePayload(cwd: string | null) {
|
||||
return {
|
||||
payload: {
|
||||
cmdText: "echo hi",
|
||||
plan: {
|
||||
argv: ["echo", "hi"],
|
||||
cwd,
|
||||
rawCommand: "echo hi",
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupSystemRunGateway(params: {
|
||||
onRunInvoke: (invokeParams: unknown) => GatewayMockResult | Promise<GatewayMockResult>;
|
||||
onApprovalRequest?: (approvalParams: unknown) => GatewayMockResult | Promise<GatewayMockResult>;
|
||||
prepareCwd?: string | null;
|
||||
}) {
|
||||
callGateway.mockImplementation(async ({ method, params: gatewayParams }: GatewayCall) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const command = (gatewayParams as { command?: string } | undefined)?.command;
|
||||
if (command === "system.run.prepare") {
|
||||
return createSystemRunPreparePayload(params.prepareCwd ?? null);
|
||||
}
|
||||
return await params.onRunInvoke(gatewayParams);
|
||||
}
|
||||
if (method === "exec.approval.request" && params.onApprovalRequest) {
|
||||
return await params.onApprovalRequest(gatewayParams);
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
callGateway.mockClear();
|
||||
});
|
||||
|
||||
describe("nodes camera_snap", () => {
|
||||
it("uses front/high-quality defaults when params are omitted", async () => {
|
||||
setupNodeInvokeMock({
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList();
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
command: "camera.snap",
|
||||
params: {
|
||||
facing: "front",
|
||||
@@ -140,8 +57,16 @@ describe("nodes camera_snap", () => {
|
||||
quality: 0.95,
|
||||
},
|
||||
});
|
||||
return { payload: JPG_PAYLOAD };
|
||||
},
|
||||
return {
|
||||
payload: {
|
||||
format: "jpg",
|
||||
base64: "aGVsbG8=",
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
@@ -149,12 +74,26 @@ describe("nodes camera_snap", () => {
|
||||
node: NODE_ID,
|
||||
});
|
||||
|
||||
expectSingleImage(result);
|
||||
const images = (result.content ?? []).filter((block) => block.type === "image");
|
||||
expect(images).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("maps jpg payloads to image/jpeg", async () => {
|
||||
setupNodeInvokeMock({
|
||||
invokePayload: JPG_PAYLOAD,
|
||||
callGateway.mockImplementation(async ({ method }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList();
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
return {
|
||||
payload: {
|
||||
format: "jpg",
|
||||
base64: "aGVsbG8=",
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
@@ -163,18 +102,31 @@ describe("nodes camera_snap", () => {
|
||||
facing: "front",
|
||||
});
|
||||
|
||||
expectSingleImage(result, { mimeType: "image/jpeg" });
|
||||
const images = (result.content ?? []).filter((block) => block.type === "image");
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0]?.mimeType).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("passes deviceId when provided", async () => {
|
||||
setupNodeInvokeMock({
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList();
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
command: "camera.snap",
|
||||
params: { deviceId: "cam-123" },
|
||||
});
|
||||
return { payload: JPG_PAYLOAD };
|
||||
},
|
||||
return {
|
||||
payload: {
|
||||
format: "jpg",
|
||||
base64: "aGVsbG8=",
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
await executeNodes({
|
||||
@@ -199,10 +151,12 @@ describe("nodes camera_snap", () => {
|
||||
|
||||
describe("nodes notifications_list", () => {
|
||||
it("invokes notifications.list and returns payload", async () => {
|
||||
setupNodeInvokeMock({
|
||||
commands: ["notifications.list"],
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["notifications.list"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
nodeId: NODE_ID,
|
||||
command: "notifications.list",
|
||||
params: {},
|
||||
@@ -215,7 +169,8 @@ describe("nodes notifications_list", () => {
|
||||
notifications: [{ key: "n1", packageName: "com.example.app" }],
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
@@ -223,16 +178,21 @@ describe("nodes notifications_list", () => {
|
||||
node: NODE_ID,
|
||||
});
|
||||
|
||||
expectFirstTextContains(result, '"notifications"');
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"notifications"'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes notifications_action", () => {
|
||||
it("invokes notifications.actions dismiss", async () => {
|
||||
setupNodeInvokeMock({
|
||||
commands: ["notifications.actions"],
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["notifications.actions"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
nodeId: NODE_ID,
|
||||
command: "notifications.actions",
|
||||
params: {
|
||||
@@ -241,7 +201,8 @@ describe("nodes notifications_action", () => {
|
||||
},
|
||||
});
|
||||
return { payload: { ok: true, key: "n1", action: "dismiss" } };
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
@@ -251,16 +212,21 @@ describe("nodes notifications_action", () => {
|
||||
notificationAction: "dismiss",
|
||||
});
|
||||
|
||||
expectFirstTextContains(result, '"dismiss"');
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"dismiss"'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes device_status and device_info", () => {
|
||||
it("invokes device.status and returns payload", async () => {
|
||||
setupNodeInvokeMock({
|
||||
commands: ["device.status", "device.info"],
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["device.status", "device.info"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
nodeId: NODE_ID,
|
||||
command: "device.status",
|
||||
params: {},
|
||||
@@ -270,7 +236,8 @@ describe("nodes device_status and device_info", () => {
|
||||
battery: { state: "charging", lowPowerModeEnabled: false },
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
@@ -278,14 +245,19 @@ describe("nodes device_status and device_info", () => {
|
||||
node: NODE_ID,
|
||||
});
|
||||
|
||||
expectFirstTextContains(result, '"battery"');
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"battery"'),
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes device.info and returns payload", async () => {
|
||||
setupNodeInvokeMock({
|
||||
commands: ["device.status", "device.info"],
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["device.status", "device.info"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
nodeId: NODE_ID,
|
||||
command: "device.info",
|
||||
params: {},
|
||||
@@ -296,7 +268,8 @@ describe("nodes device_status and device_info", () => {
|
||||
appVersion: "1.0.0",
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
@@ -304,14 +277,19 @@ describe("nodes device_status and device_info", () => {
|
||||
node: NODE_ID,
|
||||
});
|
||||
|
||||
expectFirstTextContains(result, '"systemName"');
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"systemName"'),
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes device.permissions and returns payload", async () => {
|
||||
setupNodeInvokeMock({
|
||||
commands: ["device.permissions"],
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["device.permissions"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
nodeId: NODE_ID,
|
||||
command: "device.permissions",
|
||||
params: {},
|
||||
@@ -323,7 +301,8 @@ describe("nodes device_status and device_info", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
@@ -331,14 +310,19 @@ describe("nodes device_status and device_info", () => {
|
||||
node: NODE_ID,
|
||||
});
|
||||
|
||||
expectFirstTextContains(result, '"permissions"');
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"permissions"'),
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes device.health and returns payload", async () => {
|
||||
setupNodeInvokeMock({
|
||||
commands: ["device.health"],
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["device.health"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
nodeId: NODE_ID,
|
||||
command: "device.health",
|
||||
params: {},
|
||||
@@ -349,7 +333,8 @@ describe("nodes device_status and device_info", () => {
|
||||
battery: { chargingType: "usb" },
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
@@ -357,16 +342,36 @@ describe("nodes device_status and device_info", () => {
|
||||
node: NODE_ID,
|
||||
});
|
||||
|
||||
expectFirstTextContains(result, '"memory"');
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"memory"'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes run", () => {
|
||||
it("passes invoke and command timeouts", async () => {
|
||||
setupSystemRunGateway({
|
||||
prepareCwd: "/tmp",
|
||||
onRunInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const command = (params as { command?: string } | undefined)?.command;
|
||||
if (command === "system.run.prepare") {
|
||||
return {
|
||||
payload: {
|
||||
cmdText: "echo hi",
|
||||
plan: {
|
||||
argv: ["echo", "hi"],
|
||||
cwd: "/tmp",
|
||||
rawCommand: "echo hi",
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
expect(params).toMatchObject({
|
||||
nodeId: NODE_ID,
|
||||
command: "system.run",
|
||||
timeoutMs: 45_000,
|
||||
@@ -380,7 +385,8 @@ describe("nodes run", () => {
|
||||
return {
|
||||
payload: { stdout: "", stderr: "", exitCode: 0, success: true },
|
||||
};
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
await executeNodes({
|
||||
@@ -395,13 +401,31 @@ describe("nodes run", () => {
|
||||
it("requests approval and retries with allow-once decision", async () => {
|
||||
let invokeCalls = 0;
|
||||
let approvalId: string | null = null;
|
||||
setupSystemRunGateway({
|
||||
onRunInvoke: (invokeParams) => {
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const command = (params as { command?: string } | undefined)?.command;
|
||||
if (command === "system.run.prepare") {
|
||||
return {
|
||||
payload: {
|
||||
cmdText: "echo hi",
|
||||
plan: {
|
||||
argv: ["echo", "hi"],
|
||||
cwd: null,
|
||||
rawCommand: "echo hi",
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
invokeCalls += 1;
|
||||
if (invokeCalls === 1) {
|
||||
throw new Error("SYSTEM_RUN_DENIED: approval required");
|
||||
}
|
||||
expect(invokeParams).toMatchObject({
|
||||
expect(params).toMatchObject({
|
||||
nodeId: NODE_ID,
|
||||
command: "system.run",
|
||||
params: {
|
||||
@@ -412,9 +436,9 @@ describe("nodes run", () => {
|
||||
},
|
||||
});
|
||||
return { payload: { stdout: "", stderr: "", exitCode: 0, success: true } };
|
||||
},
|
||||
onApprovalRequest: (approvalParams) => {
|
||||
expect(approvalParams).toMatchObject({
|
||||
}
|
||||
if (method === "exec.approval.request") {
|
||||
expect(params).toMatchObject({
|
||||
id: expect.any(String),
|
||||
command: "echo hi",
|
||||
commandArgv: ["echo", "hi"],
|
||||
@@ -426,11 +450,12 @@ describe("nodes run", () => {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
approvalId =
|
||||
typeof (approvalParams as { id?: unknown } | undefined)?.id === "string"
|
||||
? ((approvalParams as { id: string }).id ?? null)
|
||||
typeof (params as { id?: unknown } | undefined)?.id === "string"
|
||||
? ((params as { id: string }).id ?? null)
|
||||
: null;
|
||||
return { decision: "allow-once" };
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
await executeNodes(BASE_RUN_INPUT);
|
||||
@@ -438,36 +463,93 @@ describe("nodes run", () => {
|
||||
});
|
||||
|
||||
it("fails with user denied when approval decision is deny", async () => {
|
||||
setupSystemRunGateway({
|
||||
onRunInvoke: () => {
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const command = (params as { command?: string } | undefined)?.command;
|
||||
if (command === "system.run.prepare") {
|
||||
return {
|
||||
payload: {
|
||||
cmdText: "echo hi",
|
||||
plan: {
|
||||
argv: ["echo", "hi"],
|
||||
cwd: null,
|
||||
rawCommand: "echo hi",
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("SYSTEM_RUN_DENIED: approval required");
|
||||
},
|
||||
onApprovalRequest: () => {
|
||||
}
|
||||
if (method === "exec.approval.request") {
|
||||
return { decision: "deny" };
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: user denied");
|
||||
});
|
||||
|
||||
it("fails closed for timeout and invalid approval decisions", async () => {
|
||||
setupSystemRunGateway({
|
||||
onRunInvoke: () => {
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const command = (params as { command?: string } | undefined)?.command;
|
||||
if (command === "system.run.prepare") {
|
||||
return {
|
||||
payload: {
|
||||
cmdText: "echo hi",
|
||||
plan: {
|
||||
argv: ["echo", "hi"],
|
||||
cwd: null,
|
||||
rawCommand: "echo hi",
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("SYSTEM_RUN_DENIED: approval required");
|
||||
},
|
||||
onApprovalRequest: () => {
|
||||
}
|
||||
if (method === "exec.approval.request") {
|
||||
return {};
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: approval timed out");
|
||||
|
||||
setupSystemRunGateway({
|
||||
onRunInvoke: () => {
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const command = (params as { command?: string } | undefined)?.command;
|
||||
if (command === "system.run.prepare") {
|
||||
return {
|
||||
payload: {
|
||||
cmdText: "echo hi",
|
||||
plan: {
|
||||
argv: ["echo", "hi"],
|
||||
cwd: null,
|
||||
rawCommand: "echo hi",
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("SYSTEM_RUN_DENIED: approval required");
|
||||
},
|
||||
onApprovalRequest: () => {
|
||||
}
|
||||
if (method === "exec.approval.request") {
|
||||
return { decision: "allow-never" };
|
||||
},
|
||||
}
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow(
|
||||
"exec denied: invalid approval decision",
|
||||
|
||||
@@ -1,77 +1,83 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import * as harness from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
|
||||
const MAIN_SESSION_KEY = "agent:test:main";
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
thinking: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
sessions: {
|
||||
mainKey: "agent:test:main",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
type ThinkingLevel = "high" | "medium" | "low";
|
||||
vi.mock("../gateway/call.js", () => {
|
||||
return {
|
||||
callGateway: vi.fn(async ({ method }: { method: string }) => {
|
||||
if (method === "agent") {
|
||||
return { runId: "run-123" };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function applyThinkingDefault(thinking: ThinkingLevel) {
|
||||
harness.setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: { defaults: { subagents: { thinking } } },
|
||||
});
|
||||
type GatewayCall = { method: string; params?: Record<string, unknown> };
|
||||
|
||||
async function getGatewayCalls(): Promise<GatewayCall[]> {
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
return (callGateway as unknown as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(call) => call[0] as GatewayCall,
|
||||
);
|
||||
}
|
||||
|
||||
function findSubagentThinking(
|
||||
calls: Array<{ method?: string; params?: unknown }>,
|
||||
): string | undefined {
|
||||
for (const call of calls) {
|
||||
if (call.method !== "agent") {
|
||||
continue;
|
||||
}
|
||||
const params = call.params as { lane?: string; thinking?: string } | undefined;
|
||||
if (params?.lane === "subagent") {
|
||||
return params.thinking;
|
||||
function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) {
|
||||
for (let i = calls.length - 1; i >= 0; i -= 1) {
|
||||
const call = calls[i];
|
||||
if (call && predicate(call)) {
|
||||
return call;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findPatchedThinking(
|
||||
calls: Array<{ method?: string; params?: unknown }>,
|
||||
): string | undefined {
|
||||
for (let index = calls.length - 1; index >= 0; index -= 1) {
|
||||
const entry = calls[index];
|
||||
if (!entry || entry.method !== "sessions.patch") {
|
||||
continue;
|
||||
}
|
||||
const params = entry.params as { thinkingLevel?: string } | undefined;
|
||||
if (params?.thinkingLevel) {
|
||||
return params.thinkingLevel;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function expectThinkingPropagation(input: {
|
||||
async function expectThinkingPropagation(params: {
|
||||
callId: string;
|
||||
payload: Record<string, unknown>;
|
||||
expected: ThinkingLevel;
|
||||
expectedThinking: string;
|
||||
}) {
|
||||
const gateway = harness.setupSessionsSpawnGatewayMock({});
|
||||
const tool = await harness.getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY });
|
||||
const result = await tool.execute(input.callId, input.payload);
|
||||
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
|
||||
const result = await tool.execute(params.callId, params.payload);
|
||||
expect(result.details).toMatchObject({ status: "accepted" });
|
||||
|
||||
expect(findSubagentThinking(gateway.calls)).toBe(input.expected);
|
||||
expect(findPatchedThinking(gateway.calls)).toBe(input.expected);
|
||||
const calls = await getGatewayCalls();
|
||||
const agentCall = findLastCall(calls, (call) => call.method === "agent");
|
||||
const thinkingPatch = findLastCall(
|
||||
calls,
|
||||
(call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined,
|
||||
);
|
||||
|
||||
expect(agentCall?.params?.thinking).toBe(params.expectedThinking);
|
||||
expect(thinkingPatch?.params?.thinkingLevel).toBe(params.expectedThinking);
|
||||
}
|
||||
|
||||
describe("sessions_spawn thinking defaults", () => {
|
||||
beforeEach(() => {
|
||||
harness.resetSessionsSpawnConfigOverride();
|
||||
resetSubagentRegistryForTests();
|
||||
harness.getCallGatewayMock().mockClear();
|
||||
applyThinkingDefault("high");
|
||||
});
|
||||
|
||||
it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => {
|
||||
await expectThinkingPropagation({
|
||||
callId: "call-1",
|
||||
payload: { task: "hello" },
|
||||
expected: "high",
|
||||
expectedThinking: "high",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +85,7 @@ describe("sessions_spawn thinking defaults", () => {
|
||||
await expectThinkingPropagation({
|
||||
callId: "call-2",
|
||||
payload: { task: "hello", thinking: "low" },
|
||||
expected: "low",
|
||||
expectedThinking: "low",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,49 +1,69 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import {
|
||||
getCallGatewayMock,
|
||||
getSessionsSpawnTool,
|
||||
resetSessionsSpawnConfigOverride,
|
||||
setSessionsSpawnConfigOverride,
|
||||
setupSessionsSpawnGatewayMock,
|
||||
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
|
||||
const MAIN_SESSION_KEY = "agent:test:main";
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
maxConcurrent: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
sessions: {
|
||||
mainKey: "agent:test:main",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function configureDefaultsWithoutTimeout() {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: { defaults: { subagents: { maxConcurrent: 8 } } },
|
||||
});
|
||||
vi.mock("../gateway/call.js", () => {
|
||||
return {
|
||||
callGateway: vi.fn(async ({ method }: { method: string }) => {
|
||||
if (method === "agent") {
|
||||
return { runId: "run-456" };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => null,
|
||||
}));
|
||||
|
||||
type GatewayCall = { method: string; params?: Record<string, unknown> };
|
||||
|
||||
async function getGatewayCalls(): Promise<GatewayCall[]> {
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
return (callGateway as unknown as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(call) => call[0] as GatewayCall,
|
||||
);
|
||||
}
|
||||
|
||||
function readSpawnTimeout(calls: Array<{ method?: string; params?: unknown }>): number | undefined {
|
||||
const spawn = calls.find((entry) => {
|
||||
if (entry.method !== "agent") {
|
||||
return false;
|
||||
function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) {
|
||||
for (let i = calls.length - 1; i >= 0; i -= 1) {
|
||||
const call = calls[i];
|
||||
if (call && predicate(call)) {
|
||||
return call;
|
||||
}
|
||||
const params = entry.params as { lane?: string } | undefined;
|
||||
return params?.lane === "subagent";
|
||||
});
|
||||
const params = spawn?.params as { timeout?: number } | undefined;
|
||||
return params?.timeout;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("sessions_spawn default runTimeoutSeconds (config absent)", () => {
|
||||
beforeEach(() => {
|
||||
resetSessionsSpawnConfigOverride();
|
||||
resetSubagentRegistryForTests();
|
||||
getCallGatewayMock().mockClear();
|
||||
});
|
||||
|
||||
it("falls back to 0 (no timeout) when config key is absent", async () => {
|
||||
configureDefaultsWithoutTimeout();
|
||||
const gateway = setupSessionsSpawnGatewayMock({});
|
||||
const tool = await getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY });
|
||||
|
||||
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
|
||||
const result = await tool.execute("call-1", { task: "hello" });
|
||||
expect(result.details).toMatchObject({ status: "accepted" });
|
||||
expect(readSpawnTimeout(gateway.calls)).toBe(0);
|
||||
|
||||
const calls = await getGatewayCalls();
|
||||
const agentCall = findLastCall(calls, (call) => call.method === "agent");
|
||||
expect(agentCall?.params?.timeout).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,79 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import * as sessionsHarness from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
|
||||
const MAIN_SESSION_KEY = "agent:test:main";
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
runTimeoutSeconds: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
sessions: {
|
||||
mainKey: "agent:test:main",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function applySubagentTimeoutDefault(seconds: number) {
|
||||
sessionsHarness.setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: { defaults: { subagents: { runTimeoutSeconds: seconds } } },
|
||||
});
|
||||
vi.mock("../gateway/call.js", () => {
|
||||
return {
|
||||
callGateway: vi.fn(async ({ method }: { method: string }) => {
|
||||
if (method === "agent") {
|
||||
return { runId: "run-123" };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => null,
|
||||
}));
|
||||
|
||||
type GatewayCall = { method: string; params?: Record<string, unknown> };
|
||||
|
||||
async function getGatewayCalls(): Promise<GatewayCall[]> {
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
return (callGateway as unknown as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(call) => call[0] as GatewayCall,
|
||||
);
|
||||
}
|
||||
|
||||
function getSubagentTimeout(
|
||||
calls: Array<{ method?: string; params?: unknown }>,
|
||||
): number | undefined {
|
||||
for (const call of calls) {
|
||||
if (call.method !== "agent") {
|
||||
continue;
|
||||
}
|
||||
const params = call.params as { lane?: string; timeout?: number } | undefined;
|
||||
if (params?.lane === "subagent") {
|
||||
return params.timeout;
|
||||
function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) {
|
||||
for (let i = calls.length - 1; i >= 0; i -= 1) {
|
||||
const call = calls[i];
|
||||
if (call && predicate(call)) {
|
||||
return call;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function spawnSubagent(callId: string, payload: Record<string, unknown>) {
|
||||
const tool = await sessionsHarness.getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY });
|
||||
const result = await tool.execute(callId, payload);
|
||||
expect(result.details).toMatchObject({ status: "accepted" });
|
||||
}
|
||||
|
||||
describe("sessions_spawn default runTimeoutSeconds", () => {
|
||||
beforeEach(() => {
|
||||
sessionsHarness.resetSessionsSpawnConfigOverride();
|
||||
resetSubagentRegistryForTests();
|
||||
sessionsHarness.getCallGatewayMock().mockClear();
|
||||
});
|
||||
|
||||
it("uses config default when agent omits runTimeoutSeconds", async () => {
|
||||
applySubagentTimeoutDefault(900);
|
||||
const gateway = sessionsHarness.setupSessionsSpawnGatewayMock({});
|
||||
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
|
||||
const result = await tool.execute("call-1", { task: "hello" });
|
||||
expect(result.details).toMatchObject({ status: "accepted" });
|
||||
|
||||
await spawnSubagent("call-1", { task: "hello" });
|
||||
|
||||
expect(getSubagentTimeout(gateway.calls)).toBe(900);
|
||||
const calls = await getGatewayCalls();
|
||||
const agentCall = findLastCall(calls, (call) => call.method === "agent");
|
||||
expect(agentCall?.params?.timeout).toBe(900);
|
||||
});
|
||||
|
||||
it("explicit runTimeoutSeconds wins over config default", async () => {
|
||||
applySubagentTimeoutDefault(900);
|
||||
const gateway = sessionsHarness.setupSessionsSpawnGatewayMock({});
|
||||
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
|
||||
const result = await tool.execute("call-2", { task: "hello", runTimeoutSeconds: 300 });
|
||||
expect(result.details).toMatchObject({ status: "accepted" });
|
||||
|
||||
await spawnSubagent("call-2", { task: "hello", runTimeoutSeconds: 300 });
|
||||
|
||||
expect(getSubagentTimeout(gateway.calls)).toBe(300);
|
||||
const calls = await getGatewayCalls();
|
||||
const agentCall = findLastCall(calls, (call) => call.method === "agent");
|
||||
expect(agentCall?.params?.timeout).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { vi, type Mock } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
|
||||
type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
|
||||
type CreateSessionsSpawnTool =
|
||||
@@ -16,6 +16,10 @@ type SessionsSpawnGatewayMockOptions = {
|
||||
agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number };
|
||||
};
|
||||
|
||||
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
type AnyMock = any;
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const callGatewayMock = vi.fn();
|
||||
const defaultConfigOverride = {
|
||||
@@ -28,12 +32,12 @@ const hoisted = vi.hoisted(() => {
|
||||
return { callGatewayMock, defaultConfigOverride, state };
|
||||
});
|
||||
|
||||
export function getCallGatewayMock(): Mock {
|
||||
export function getCallGatewayMock(): AnyMock {
|
||||
return hoisted.callGatewayMock;
|
||||
}
|
||||
|
||||
export function getGatewayRequests(): Array<GatewayRequest> {
|
||||
return getCallGatewayMock().mock.calls.map((call: unknown[]) => call[0] as GatewayRequest);
|
||||
return getCallGatewayMock().mock.calls.map((call: [unknown]) => call[0] as GatewayRequest);
|
||||
}
|
||||
|
||||
export function getGatewayMethods(): Array<string | undefined> {
|
||||
|
||||
@@ -320,55 +320,54 @@ describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
});
|
||||
|
||||
describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
|
||||
const callIdWithReasoning = "call_123|fc_123";
|
||||
const callIdWithoutReasoning = "call_123";
|
||||
const readArgs = {} as Record<string, never>;
|
||||
|
||||
const makeToolCall = (id: string) => ({
|
||||
type: "toolCall",
|
||||
id,
|
||||
name: "read",
|
||||
arguments: readArgs,
|
||||
});
|
||||
const makeToolResult = (toolCallId: string, text: string) => ({
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
const makeReasoningAssistantTurn = (id: string) => ({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
makeToolCall(id),
|
||||
],
|
||||
});
|
||||
const makePlainAssistantTurn = (id: string) => ({
|
||||
role: "assistant",
|
||||
content: [makeToolCall(id)],
|
||||
});
|
||||
|
||||
it("strips fc ids when reasoning cannot be replayed", () => {
|
||||
const input = [
|
||||
makePlainAssistantTurn(callIdWithReasoning),
|
||||
makeToolResult(callIdWithReasoning, "ok"),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
];
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([
|
||||
makePlainAssistantTurn(callIdWithoutReasoning),
|
||||
makeToolResult(callIdWithoutReasoning, "ok"),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps fc ids when replayable reasoning is present", () => {
|
||||
const input = [
|
||||
makeReasoningAssistantTurn(callIdWithReasoning),
|
||||
makeToolResult(callIdWithReasoning, "ok"),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
];
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
@@ -377,18 +376,64 @@ describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
|
||||
|
||||
it("only rewrites tool results paired to the downgraded assistant turn", () => {
|
||||
const input = [
|
||||
makePlainAssistantTurn(callIdWithReasoning),
|
||||
makeToolResult(callIdWithReasoning, "turn1"),
|
||||
makeReasoningAssistantTurn(callIdWithReasoning),
|
||||
makeToolResult(callIdWithReasoning, "turn2"),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "turn1" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "turn2" }],
|
||||
},
|
||||
];
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([
|
||||
makePlainAssistantTurn(callIdWithoutReasoning),
|
||||
makeToolResult(callIdWithoutReasoning, "turn1"),
|
||||
makeReasoningAssistantTurn(callIdWithReasoning),
|
||||
makeToolResult(callIdWithReasoning, "turn2"),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "turn1" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "turn2" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,66 +7,92 @@ import {
|
||||
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
||||
|
||||
describe("sanitizeSessionHistory openai tool id preservation", () => {
|
||||
const makeSessionManager = () =>
|
||||
makeInMemorySessionManager([
|
||||
it("strips fc ids when replayable reasoning metadata is missing", async () => {
|
||||
const sessionEntries = [
|
||||
makeModelSnapshotEntry({
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.2-codex",
|
||||
}),
|
||||
]);
|
||||
];
|
||||
const sessionManager = makeInMemorySessionManager(sessionEntries);
|
||||
|
||||
const makeMessages = (withReasoning: boolean): AgentMessage[] => [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
...(withReasoning
|
||||
? [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal reasoning",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} },
|
||||
],
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }],
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "strips fc ids when replayable reasoning metadata is missing",
|
||||
withReasoning: false,
|
||||
expectedToolId: "call_123",
|
||||
},
|
||||
{
|
||||
name: "keeps canonical call_id|fc_id pairings when replayable reasoning is present",
|
||||
withReasoning: true,
|
||||
expectedToolId: "call_123|fc_123",
|
||||
},
|
||||
])("$name", async ({ withReasoning, expectedToolId }) => {
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages: makeMessages(withReasoning),
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager: makeSessionManager(),
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> };
|
||||
const toolCall = assistant.content?.find((block) => block.type === "toolCall");
|
||||
expect(toolCall?.id).toBe(expectedToolId);
|
||||
expect(toolCall?.id).toBe("call_123");
|
||||
|
||||
const toolResult = result[1] as { toolCallId?: string };
|
||||
expect(toolResult.toolCallId).toBe(expectedToolId);
|
||||
expect(toolResult.toolCallId).toBe("call_123");
|
||||
});
|
||||
|
||||
it("keeps canonical call_id|fc_id pairings when replayable reasoning is present", async () => {
|
||||
const sessionEntries = [
|
||||
makeModelSnapshotEntry({
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.2-codex",
|
||||
}),
|
||||
];
|
||||
const sessionManager = makeInMemorySessionManager(sessionEntries);
|
||||
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal reasoning",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} },
|
||||
],
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> };
|
||||
const toolCall = assistant.content?.find((block) => block.type === "toolCall");
|
||||
expect(toolCall?.id).toBe("call_123|fc_123");
|
||||
|
||||
const toolResult = result[1] as { toolCallId?: string };
|
||||
expect(toolResult.toolCallId).toBe("call_123|fc_123");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,54 +74,6 @@ describe("sanitizeSessionHistory", () => {
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const makeUsage = (input: number, output: number, totalTokens: number) => ({
|
||||
input,
|
||||
output,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
});
|
||||
|
||||
const makeAssistantUsageMessage = (params: {
|
||||
text: string;
|
||||
usage: ReturnType<typeof makeUsage>;
|
||||
timestamp?: number;
|
||||
}) =>
|
||||
({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: params.text }],
|
||||
stopReason: "stop",
|
||||
...(typeof params.timestamp === "number" ? { timestamp: params.timestamp } : {}),
|
||||
usage: params.usage,
|
||||
}) as unknown as AgentMessage;
|
||||
|
||||
const makeCompactionSummaryMessage = (tokensBefore: number, timestamp: string) =>
|
||||
({
|
||||
role: "compactionSummary",
|
||||
summary: "compressed",
|
||||
tokensBefore,
|
||||
timestamp,
|
||||
}) as unknown as AgentMessage;
|
||||
|
||||
const sanitizeOpenAIHistory = async (
|
||||
messages: AgentMessage[],
|
||||
overrides: Partial<Parameters<SanitizeSessionHistoryFn>[0]> = {},
|
||||
) =>
|
||||
sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const getAssistantMessages = (messages: AgentMessage[]) =>
|
||||
messages.filter((message) => message.role === "assistant") as Array<
|
||||
AgentMessage & { usage?: unknown; content?: unknown }
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks();
|
||||
});
|
||||
@@ -226,14 +178,34 @@ describe("sanitizeSessionHistory", () => {
|
||||
|
||||
const messages = [
|
||||
{ role: "user", content: "old context" },
|
||||
makeAssistantUsageMessage({
|
||||
text: "old answer",
|
||||
usage: makeUsage(191_919, 2_000, 193_919),
|
||||
}),
|
||||
makeCompactionSummaryMessage(191_919, new Date().toISOString()),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "old answer" }],
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 191_919,
|
||||
output: 2_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 193_919,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "compressed",
|
||||
tokensBefore: 191_919,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages);
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
const staleAssistant = result.find((message) => message.role === "assistant") as
|
||||
| (AgentMessage & { usage?: unknown })
|
||||
@@ -246,21 +218,52 @@ describe("sanitizeSessionHistory", () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
const messages = [
|
||||
makeAssistantUsageMessage({
|
||||
text: "pre-compaction answer",
|
||||
usage: makeUsage(120_000, 3_000, 123_000),
|
||||
}),
|
||||
makeCompactionSummaryMessage(123_000, new Date().toISOString()),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "pre-compaction answer" }],
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 120_000,
|
||||
output: 3_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 123_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "compressed",
|
||||
tokensBefore: 123_000,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ role: "user", content: "new question" },
|
||||
makeAssistantUsageMessage({
|
||||
text: "fresh answer",
|
||||
usage: makeUsage(1_000, 250, 1_250),
|
||||
}),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "fresh answer" }],
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1_000,
|
||||
output: 250,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1_250,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages);
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
const assistants = getAssistantMessages(result);
|
||||
const assistants = result.filter((message) => message.role === "assistant") as Array<
|
||||
AgentMessage & { usage?: unknown }
|
||||
>;
|
||||
expect(assistants).toHaveLength(2);
|
||||
expect(assistants[0]?.usage).toEqual(makeZeroUsageSnapshot());
|
||||
expect(assistants[1]?.usage).toBeDefined();
|
||||
@@ -271,15 +274,35 @@ describe("sanitizeSessionHistory", () => {
|
||||
|
||||
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
|
||||
const messages = [
|
||||
makeCompactionSummaryMessage(191_919, new Date(compactionTs).toISOString()),
|
||||
makeAssistantUsageMessage({
|
||||
text: "kept pre-compaction answer",
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "compressed",
|
||||
tokensBefore: 191_919,
|
||||
timestamp: new Date(compactionTs).toISOString(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "kept pre-compaction answer" }],
|
||||
stopReason: "stop",
|
||||
timestamp: compactionTs - 1_000,
|
||||
usage: makeUsage(191_919, 2_000, 193_919),
|
||||
}),
|
||||
usage: {
|
||||
input: 191_919,
|
||||
output: 2_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 193_919,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages);
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
const assistant = result.find((message) => message.role === "assistant") as
|
||||
| (AgentMessage & { usage?: unknown })
|
||||
@@ -292,23 +315,54 @@ describe("sanitizeSessionHistory", () => {
|
||||
|
||||
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
|
||||
const messages = [
|
||||
makeCompactionSummaryMessage(123_000, new Date(compactionTs).toISOString()),
|
||||
makeAssistantUsageMessage({
|
||||
text: "kept pre-compaction answer",
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "compressed",
|
||||
tokensBefore: 123_000,
|
||||
timestamp: new Date(compactionTs).toISOString(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "kept pre-compaction answer" }],
|
||||
stopReason: "stop",
|
||||
timestamp: compactionTs - 2_000,
|
||||
usage: makeUsage(120_000, 3_000, 123_000),
|
||||
}),
|
||||
usage: {
|
||||
input: 120_000,
|
||||
output: 3_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 123_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
{ role: "user", content: "new question", timestamp: compactionTs + 1_000 },
|
||||
makeAssistantUsageMessage({
|
||||
text: "fresh answer",
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "fresh answer" }],
|
||||
stopReason: "stop",
|
||||
timestamp: compactionTs + 2_000,
|
||||
usage: makeUsage(1_000, 250, 1_250),
|
||||
}),
|
||||
usage: {
|
||||
input: 1_000,
|
||||
output: 250,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1_250,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages);
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
const assistants = getAssistantMessages(result);
|
||||
const assistants = result.filter((message) => message.role === "assistant") as Array<
|
||||
AgentMessage & { usage?: unknown; content?: unknown }
|
||||
>;
|
||||
const keptAssistant = assistants.find((message) =>
|
||||
JSON.stringify(message.content).includes("kept pre-compaction answer"),
|
||||
);
|
||||
@@ -357,7 +411,13 @@ describe("sanitizeSessionHistory", () => {
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages);
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
// repairToolUseResultPairing now runs for all providers (including OpenAI)
|
||||
// to fix orphaned function_call_output items that OpenAI would reject.
|
||||
@@ -375,7 +435,13 @@ describe("sanitizeSessionHistory", () => {
|
||||
{ role: "user", content: "hello" },
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages, { sessionId: "test-session" });
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(result.map((msg) => msg.role)).toEqual(["user"]);
|
||||
});
|
||||
@@ -397,7 +463,13 @@ describe("sanitizeSessionHistory", () => {
|
||||
{ role: "user", content: "hello" },
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages);
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
expect(result.map((msg) => msg.role)).toEqual(["user"]);
|
||||
});
|
||||
@@ -410,8 +482,13 @@ describe("sanitizeSessionHistory", () => {
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages, {
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
allowedToolNames: ["read"],
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
|
||||
@@ -12,21 +12,6 @@ import {
|
||||
wrapStreamFnTrimToolCallNames,
|
||||
} from "./attempt.js";
|
||||
|
||||
function createOllamaProviderConfig(injectNumCtxForOpenAICompat: boolean): OpenClawConfig {
|
||||
return {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
api: "openai-completions",
|
||||
injectNumCtxForOpenAICompat,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolvePromptBuildHookResult", () => {
|
||||
function createLegacyOnlyHookRunner() {
|
||||
return {
|
||||
@@ -144,25 +129,6 @@ describe("wrapStreamFnTrimToolCallNames", () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeWrappedStream(
|
||||
baseFn: (...args: never[]) => unknown,
|
||||
allowedToolNames?: Set<string>,
|
||||
) {
|
||||
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, allowedToolNames);
|
||||
return await wrappedFn({} as never, {} as never, {} as never);
|
||||
}
|
||||
|
||||
function createEventStream(params: {
|
||||
event: unknown;
|
||||
finalToolCall: { type: string; name: string };
|
||||
}) {
|
||||
const finalMessage = { role: "assistant", content: [params.finalToolCall] };
|
||||
const baseFn = vi.fn(() =>
|
||||
createFakeStream({ events: [params.event], resultMessage: finalMessage }),
|
||||
);
|
||||
return { baseFn, finalMessage };
|
||||
}
|
||||
|
||||
it("trims whitespace from live streamed tool call names and final result message", async () => {
|
||||
const partialToolCall = { type: "toolCall", name: " read " };
|
||||
const messageToolCall = { type: "toolCall", name: " exec " };
|
||||
@@ -172,9 +138,13 @@ describe("wrapStreamFnTrimToolCallNames", () => {
|
||||
partial: { role: "assistant", content: [partialToolCall] },
|
||||
message: { role: "assistant", content: [messageToolCall] },
|
||||
};
|
||||
const { baseFn, finalMessage } = createEventStream({ event, finalToolCall });
|
||||
const finalMessage = { role: "assistant", content: [finalToolCall] };
|
||||
const baseFn = vi.fn(() => createFakeStream({ events: [event], resultMessage: finalMessage }));
|
||||
|
||||
const stream = await invokeWrappedStream(baseFn);
|
||||
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never);
|
||||
const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited<
|
||||
ReturnType<typeof wrappedFn>
|
||||
>;
|
||||
|
||||
const seenEvents: unknown[] = [];
|
||||
for await (const item of stream) {
|
||||
@@ -200,7 +170,8 @@ describe("wrapStreamFnTrimToolCallNames", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const stream = await invokeWrappedStream(baseFn);
|
||||
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never);
|
||||
const stream = await wrappedFn({} as never, {} as never, {} as never);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(finalToolCall.name).toBe("browser");
|
||||
@@ -217,7 +188,10 @@ describe("wrapStreamFnTrimToolCallNames", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const stream = await invokeWrappedStream(baseFn, new Set(["exec"]));
|
||||
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, new Set(["exec"]));
|
||||
const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited<
|
||||
ReturnType<typeof wrappedFn>
|
||||
>;
|
||||
const result = await stream.result();
|
||||
|
||||
expect(finalToolCall.name).toBe("exec");
|
||||
@@ -231,9 +205,13 @@ describe("wrapStreamFnTrimToolCallNames", () => {
|
||||
type: "toolcall_delta",
|
||||
partial: { role: "assistant", content: [partialToolCall] },
|
||||
};
|
||||
const { baseFn } = createEventStream({ event, finalToolCall });
|
||||
const finalMessage = { role: "assistant", content: [finalToolCall] };
|
||||
const baseFn = vi.fn(() => createFakeStream({ events: [event], resultMessage: finalMessage }));
|
||||
|
||||
const stream = await invokeWrappedStream(baseFn);
|
||||
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never);
|
||||
const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited<
|
||||
ReturnType<typeof wrappedFn>
|
||||
>;
|
||||
|
||||
for await (const _item of stream) {
|
||||
// drain
|
||||
@@ -368,7 +346,18 @@ describe("resolveOllamaCompatNumCtxEnabled", () => {
|
||||
it("returns false when provider flag is explicitly disabled", () => {
|
||||
expect(
|
||||
resolveOllamaCompatNumCtxEnabled({
|
||||
config: createOllamaProviderConfig(false),
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
api: "openai-completions",
|
||||
injectNumCtxForOpenAICompat: false,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
providerId: "ollama",
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -396,7 +385,18 @@ describe("shouldInjectOllamaCompatNumCtx", () => {
|
||||
api: "openai-completions",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
},
|
||||
config: createOllamaProviderConfig(false),
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
api: "openai-completions",
|
||||
injectNumCtxForOpenAICompat: false,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
providerId: "ollama",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DEFAULT_OPENCLAW_BROWSER_COLOR,
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
} from "../../browser/constants.js";
|
||||
import { deriveDefaultBrowserCdpPortRange } from "../../config/port-defaults.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
||||
import { computeSandboxBrowserConfigHash } from "./config-hash.js";
|
||||
@@ -71,7 +70,6 @@ function buildSandboxBrowserResolvedConfig(params: {
|
||||
evaluateEnabled: boolean;
|
||||
}): ResolvedBrowserConfig {
|
||||
const cdpHost = "127.0.0.1";
|
||||
const cdpPortRange = deriveDefaultBrowserCdpPortRange(params.controlPort);
|
||||
return {
|
||||
enabled: true,
|
||||
evaluateEnabled: params.evaluateEnabled,
|
||||
@@ -79,8 +77,6 @@ function buildSandboxBrowserResolvedConfig(params: {
|
||||
cdpProtocol: "http",
|
||||
cdpHost,
|
||||
cdpIsLoopback: true,
|
||||
cdpPortRangeStart: cdpPortRange.start,
|
||||
cdpPortRangeEnd: cdpPortRange.end,
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
color: DEFAULT_OPENCLAW_BROWSER_COLOR,
|
||||
|
||||
@@ -36,14 +36,6 @@ function findCallByScriptFragment(fragment: string) {
|
||||
return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerScript(args).includes(fragment));
|
||||
}
|
||||
|
||||
function dockerExecResult(stdout: string) {
|
||||
return {
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createSandbox(overrides?: Partial<SandboxContext>): SandboxContext {
|
||||
return createSandboxTestContext({
|
||||
overrides: {
|
||||
@@ -66,37 +58,38 @@ async function withTempDir<T>(prefix: string, run: (stateDir: string) => Promise
|
||||
}
|
||||
}
|
||||
|
||||
function installDockerReadMock(params?: { canonicalPath?: string }) {
|
||||
const canonicalPath = params?.canonicalPath;
|
||||
mockedExecDockerRaw.mockImplementation(async (args) => {
|
||||
const script = getDockerScript(args);
|
||||
if (script.includes('readlink -f -- "$cursor"')) {
|
||||
return dockerExecResult(`${canonicalPath ?? getDockerArg(args, 1)}\n`);
|
||||
}
|
||||
if (script.includes('stat -c "%F|%s|%Y"')) {
|
||||
return dockerExecResult("regular file|1|2");
|
||||
}
|
||||
if (script.includes('cat -- "$1"')) {
|
||||
return dockerExecResult("content");
|
||||
}
|
||||
return dockerExecResult("");
|
||||
});
|
||||
}
|
||||
|
||||
async function createHostEscapeFixture(stateDir: string) {
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
const outsideDir = path.join(stateDir, "outside");
|
||||
const outsideFile = path.join(outsideDir, "secret.txt");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(outsideFile, "classified");
|
||||
return { workspaceDir, outsideFile };
|
||||
}
|
||||
|
||||
describe("sandbox fs bridge shell compatibility", () => {
|
||||
beforeEach(() => {
|
||||
mockedExecDockerRaw.mockClear();
|
||||
installDockerReadMock();
|
||||
mockedExecDockerRaw.mockImplementation(async (args) => {
|
||||
const script = getDockerScript(args);
|
||||
if (script.includes('readlink -f -- "$cursor"')) {
|
||||
return {
|
||||
stdout: Buffer.from(`${getDockerArg(args, 1)}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
if (script.includes('stat -c "%F|%s|%Y"')) {
|
||||
return {
|
||||
stdout: Buffer.from("regular file|1|2"),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
if (script.includes('cat -- "$1"')) {
|
||||
return {
|
||||
stdout: Buffer.from("content"),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it("uses POSIX-safe shell prologue in all bridge commands", async () => {
|
||||
@@ -234,7 +227,12 @@ describe("sandbox fs bridge shell compatibility", () => {
|
||||
|
||||
it("rejects pre-existing host symlink escapes before docker exec", async () => {
|
||||
await withTempDir("openclaw-fs-bridge-", async (stateDir) => {
|
||||
const { workspaceDir, outsideFile } = await createHostEscapeFixture(stateDir);
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
const outsideDir = path.join(stateDir, "outside");
|
||||
const outsideFile = path.join(outsideDir, "secret.txt");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(outsideFile, "classified");
|
||||
await fs.symlink(outsideFile, path.join(workspaceDir, "link.txt"));
|
||||
|
||||
const bridge = createSandboxFsBridge({
|
||||
@@ -254,7 +252,12 @@ describe("sandbox fs bridge shell compatibility", () => {
|
||||
return;
|
||||
}
|
||||
await withTempDir("openclaw-fs-bridge-hardlink-", async (stateDir) => {
|
||||
const { workspaceDir, outsideFile } = await createHostEscapeFixture(stateDir);
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
const outsideDir = path.join(stateDir, "outside");
|
||||
const outsideFile = path.join(outsideDir, "secret.txt");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(outsideFile, "classified");
|
||||
const hardlinkPath = path.join(workspaceDir, "link.txt");
|
||||
try {
|
||||
await fs.link(outsideFile, hardlinkPath);
|
||||
@@ -278,7 +281,28 @@ describe("sandbox fs bridge shell compatibility", () => {
|
||||
});
|
||||
|
||||
it("rejects container-canonicalized paths outside allowed mounts", async () => {
|
||||
installDockerReadMock({ canonicalPath: "/etc/passwd" });
|
||||
mockedExecDockerRaw.mockImplementation(async (args) => {
|
||||
const script = getDockerScript(args);
|
||||
if (script.includes('readlink -f -- "$cursor"')) {
|
||||
return {
|
||||
stdout: Buffer.from("/etc/passwd\n"),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
if (script.includes('cat -- "$1"')) {
|
||||
return {
|
||||
stdout: Buffer.from("content"),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
|
||||
await expect(bridge.readFile({ filePath: "a.txt" })).rejects.toThrow(/escapes allowed mounts/i);
|
||||
|
||||
@@ -239,28 +239,6 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
});
|
||||
|
||||
describe("sanitizeToolCallInputs", () => {
|
||||
function sanitizeAssistantContent(
|
||||
content: unknown[],
|
||||
options?: Parameters<typeof sanitizeToolCallInputs>[1],
|
||||
) {
|
||||
return sanitizeToolCallInputs(
|
||||
[
|
||||
{
|
||||
role: "assistant",
|
||||
content,
|
||||
},
|
||||
] as unknown as AgentMessage[],
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeAssistantToolCalls(
|
||||
content: unknown[],
|
||||
options?: Parameters<typeof sanitizeToolCallInputs>[1],
|
||||
) {
|
||||
return getAssistantToolCallBlocks(sanitizeAssistantContent(content, options));
|
||||
}
|
||||
|
||||
it("drops tool calls missing input or arguments", () => {
|
||||
const input = [
|
||||
{
|
||||
@@ -274,54 +252,71 @@ describe("sanitizeToolCallInputs", () => {
|
||||
expect(out.map((m) => m.role)).toEqual(["user"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "drops tool calls with missing or blank name/id",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_ok", name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: "call_empty_name", name: "", arguments: {} },
|
||||
{ type: "toolUse", id: "call_blank_name", name: " ", input: {} },
|
||||
{ type: "functionCall", id: "", name: "exec", arguments: {} },
|
||||
],
|
||||
options: undefined,
|
||||
expectedIds: ["call_ok"],
|
||||
},
|
||||
{
|
||||
name: "drops tool calls with malformed or overlong names",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_ok", name: "read", arguments: {} },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_bad_chars",
|
||||
name: 'toolu_01abc <|tool_call_argument_begin|> {"command"',
|
||||
arguments: {},
|
||||
},
|
||||
{
|
||||
type: "toolUse",
|
||||
id: "call_too_long",
|
||||
name: `read_${"x".repeat(80)}`,
|
||||
input: {},
|
||||
},
|
||||
],
|
||||
options: undefined,
|
||||
expectedIds: ["call_ok"],
|
||||
},
|
||||
{
|
||||
name: "drops unknown tool names when an allowlist is provided",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_ok", name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: "call_unknown", name: "write", arguments: {} },
|
||||
],
|
||||
options: { allowedToolNames: ["read"] },
|
||||
expectedIds: ["call_ok"],
|
||||
},
|
||||
])("$name", ({ content, options, expectedIds }) => {
|
||||
const toolCalls = sanitizeAssistantToolCalls(content, options);
|
||||
const ids = toolCalls
|
||||
.map((toolCall) => (toolCall as { id?: unknown }).id)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
it("drops tool calls with missing or blank name/id", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_ok", name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: "call_empty_name", name: "", arguments: {} },
|
||||
{ type: "toolUse", id: "call_blank_name", name: " ", input: {} },
|
||||
{ type: "functionCall", id: "", name: "exec", arguments: {} },
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
expect(ids).toEqual(expectedIds);
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect((toolCalls[0] as { id?: unknown }).id).toBe("call_ok");
|
||||
});
|
||||
|
||||
it("drops tool calls with malformed or overlong names", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_ok", name: "read", arguments: {} },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_bad_chars",
|
||||
name: 'toolu_01abc <|tool_call_argument_begin|> {"command"',
|
||||
arguments: {},
|
||||
},
|
||||
{
|
||||
type: "toolUse",
|
||||
id: "call_too_long",
|
||||
name: `read_${"x".repeat(80)}`,
|
||||
input: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
|
||||
});
|
||||
|
||||
it("drops unknown tool names when an allowlist is provided", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_ok", name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: "call_unknown", name: "write", arguments: {} },
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] });
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
|
||||
});
|
||||
|
||||
it("keeps valid tool calls and preserves text blocks", () => {
|
||||
@@ -344,43 +339,71 @@ describe("sanitizeToolCallInputs", () => {
|
||||
expect(types).toEqual(["text", "toolUse"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "trims leading whitespace from tool names",
|
||||
content: [{ type: "toolCall", id: "call_1", name: " read", arguments: {} }],
|
||||
options: undefined,
|
||||
expectedNames: ["read"],
|
||||
},
|
||||
{
|
||||
name: "trims trailing whitespace from tool names",
|
||||
content: [{ type: "toolUse", id: "call_1", name: "exec ", input: { command: "ls" } }],
|
||||
options: undefined,
|
||||
expectedNames: ["exec"],
|
||||
},
|
||||
{
|
||||
name: "trims both leading and trailing whitespace from tool names",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_1", name: " read ", arguments: {} },
|
||||
{ type: "toolUse", id: "call_2", name: " exec ", input: {} },
|
||||
],
|
||||
options: undefined,
|
||||
expectedNames: ["read", "exec"],
|
||||
},
|
||||
{
|
||||
name: "trims tool names and matches against allowlist",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_1", name: " read ", arguments: {} },
|
||||
{ type: "toolCall", id: "call_2", name: " write ", arguments: {} },
|
||||
],
|
||||
options: { allowedToolNames: ["read"] },
|
||||
expectedNames: ["read"],
|
||||
},
|
||||
])("$name", ({ content, options, expectedNames }) => {
|
||||
const toolCalls = sanitizeAssistantToolCalls(content, options);
|
||||
const names = toolCalls
|
||||
.map((toolCall) => (toolCall as { name?: unknown }).name)
|
||||
.filter((name): name is string => typeof name === "string");
|
||||
expect(names).toEqual(expectedNames);
|
||||
it("trims leading whitespace from tool names", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: " read", arguments: {} }],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace from tool names", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolUse", id: "call_1", name: "exec ", input: { command: "ls" } }],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect((toolCalls[0] as { name?: unknown }).name).toBe("exec");
|
||||
});
|
||||
|
||||
it("trims both leading and trailing whitespace from tool names", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_1", name: " read ", arguments: {} },
|
||||
{ type: "toolUse", id: "call_2", name: " exec ", input: {} },
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
|
||||
expect(toolCalls).toHaveLength(2);
|
||||
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
|
||||
expect((toolCalls[1] as { name?: unknown }).name).toBe("exec");
|
||||
});
|
||||
|
||||
it("trims tool names and matches against allowlist", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_1", name: " read ", arguments: {} },
|
||||
{ type: "toolCall", id: "call_2", name: " write ", arguments: {} },
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] });
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
|
||||
});
|
||||
|
||||
it("preserves toolUse input shape for sessions_spawn when no attachments are present", () => {
|
||||
@@ -435,9 +458,17 @@ describe("sanitizeToolCallInputs", () => {
|
||||
expect(attachments[0]?.content).toBe("__OPENCLAW_REDACTED__");
|
||||
});
|
||||
it("preserves other block properties when trimming tool names", () => {
|
||||
const toolCalls = sanitizeAssistantToolCalls([
|
||||
{ type: "toolCall", id: "call_1", name: " read ", arguments: { path: "/tmp/test" } },
|
||||
]);
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_1", name: " read ", arguments: { path: "/tmp/test" } },
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
|
||||
|
||||
@@ -33,20 +33,6 @@ async function expectLockRemovedOnlyAfterFinalRelease(params: {
|
||||
await expect(fs.access(params.lockPath)).rejects.toThrow();
|
||||
}
|
||||
|
||||
async function expectCurrentPidOwnsLock(params: {
|
||||
sessionFile: string;
|
||||
timeoutMs: number;
|
||||
staleMs?: number;
|
||||
}) {
|
||||
const { sessionFile, timeoutMs, staleMs } = params;
|
||||
const lockPath = `${sessionFile}.lock`;
|
||||
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs, staleMs });
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
const payload = JSON.parse(raw) as { pid: number };
|
||||
expect(payload.pid).toBe(process.pid);
|
||||
await lock.release();
|
||||
}
|
||||
|
||||
describe("acquireSessionWriteLock", () => {
|
||||
it("reuses locks across symlinked session paths", async () => {
|
||||
if (process.platform === "win32") {
|
||||
@@ -104,7 +90,12 @@ describe("acquireSessionWriteLock", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expectCurrentPidOwnsLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
|
||||
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
const payload = JSON.parse(raw) as { pid: number };
|
||||
|
||||
expect(payload.pid).toBe(process.pid);
|
||||
await lock.release();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -294,7 +285,12 @@ describe("acquireSessionWriteLock", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expectCurrentPidOwnsLock({ sessionFile, timeoutMs: 500 });
|
||||
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
const payload = JSON.parse(raw) as { pid: number };
|
||||
|
||||
expect(payload.pid).toBe(process.pid);
|
||||
await lock.release();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -47,93 +47,85 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin
|
||||
};
|
||||
}
|
||||
|
||||
function createSinglePluginRegistry(params: {
|
||||
pluginRoot: string;
|
||||
skills: string[];
|
||||
}): PluginManifestRegistry {
|
||||
return {
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "helper",
|
||||
name: "Helper",
|
||||
channels: [],
|
||||
providers: [],
|
||||
skills: params.skills,
|
||||
origin: "workspace",
|
||||
rootDir: params.pluginRoot,
|
||||
source: params.pluginRoot,
|
||||
manifestPath: path.join(params.pluginRoot, "openclaw.plugin.json"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function setupAcpxAndHelperRegistry() {
|
||||
const workspaceDir = await tempDirs.make("openclaw-");
|
||||
const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
|
||||
const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
|
||||
await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
|
||||
await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ acpxRoot, helperRoot }));
|
||||
return { workspaceDir, acpxRoot, helperRoot };
|
||||
}
|
||||
|
||||
async function setupPluginOutsideSkills() {
|
||||
const workspaceDir = await tempDirs.make("openclaw-");
|
||||
const pluginRoot = await tempDirs.make("openclaw-plugin-");
|
||||
const outsideDir = await tempDirs.make("openclaw-outside-");
|
||||
const outsideSkills = path.join(outsideDir, "skills");
|
||||
return { workspaceDir, pluginRoot, outsideSkills };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
hoisted.loadPluginManifestRegistry.mockReset();
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
describe("resolvePluginSkillDirs", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "keeps acpx plugin skills when ACP is enabled",
|
||||
acpEnabled: true,
|
||||
expectedDirs: ({ acpxRoot, helperRoot }: { acpxRoot: string; helperRoot: string }) => [
|
||||
path.resolve(acpxRoot, "skills"),
|
||||
path.resolve(helperRoot, "skills"),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "skips acpx plugin skills when ACP is disabled",
|
||||
acpEnabled: false,
|
||||
expectedDirs: ({ helperRoot }: { acpxRoot: string; helperRoot: string }) => [
|
||||
path.resolve(helperRoot, "skills"),
|
||||
],
|
||||
},
|
||||
])("$name", async ({ acpEnabled, expectedDirs }) => {
|
||||
const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry();
|
||||
it("keeps acpx plugin skills when ACP is enabled", async () => {
|
||||
const workspaceDir = await tempDirs.make("openclaw-");
|
||||
const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
|
||||
const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
|
||||
await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
|
||||
await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
|
||||
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue(
|
||||
buildRegistry({
|
||||
acpxRoot,
|
||||
helperRoot,
|
||||
}),
|
||||
);
|
||||
|
||||
const dirs = resolvePluginSkillDirs({
|
||||
workspaceDir,
|
||||
config: {
|
||||
acp: { enabled: acpEnabled },
|
||||
acp: { enabled: true },
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(dirs).toEqual(expectedDirs({ acpxRoot, helperRoot }));
|
||||
expect(dirs).toEqual([path.resolve(acpxRoot, "skills"), path.resolve(helperRoot, "skills")]);
|
||||
});
|
||||
|
||||
it("skips acpx plugin skills when ACP is disabled", async () => {
|
||||
const workspaceDir = await tempDirs.make("openclaw-");
|
||||
const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
|
||||
const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
|
||||
await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
|
||||
await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
|
||||
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue(
|
||||
buildRegistry({
|
||||
acpxRoot,
|
||||
helperRoot,
|
||||
}),
|
||||
);
|
||||
|
||||
const dirs = resolvePluginSkillDirs({
|
||||
workspaceDir,
|
||||
config: {
|
||||
acp: { enabled: false },
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(dirs).toEqual([path.resolve(helperRoot, "skills")]);
|
||||
});
|
||||
|
||||
it("rejects plugin skill paths that escape the plugin root", async () => {
|
||||
const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills();
|
||||
const workspaceDir = await tempDirs.make("openclaw-");
|
||||
const pluginRoot = await tempDirs.make("openclaw-plugin-");
|
||||
const outsideDir = await tempDirs.make("openclaw-outside-");
|
||||
const outsideSkills = path.join(outsideDir, "skills");
|
||||
await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true });
|
||||
await fs.mkdir(outsideSkills, { recursive: true });
|
||||
const escapePath = path.relative(pluginRoot, outsideSkills);
|
||||
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue(
|
||||
createSinglePluginRegistry({
|
||||
pluginRoot,
|
||||
skills: ["./skills", escapePath],
|
||||
}),
|
||||
);
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "helper",
|
||||
name: "Helper",
|
||||
channels: [],
|
||||
providers: [],
|
||||
skills: ["./skills", escapePath],
|
||||
origin: "workspace",
|
||||
rootDir: pluginRoot,
|
||||
source: pluginRoot,
|
||||
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
|
||||
},
|
||||
],
|
||||
} satisfies PluginManifestRegistry);
|
||||
|
||||
const dirs = resolvePluginSkillDirs({
|
||||
workspaceDir,
|
||||
@@ -144,7 +136,10 @@ describe("resolvePluginSkillDirs", () => {
|
||||
});
|
||||
|
||||
it("rejects plugin skill symlinks that resolve outside plugin root", async () => {
|
||||
const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills();
|
||||
const workspaceDir = await tempDirs.make("openclaw-");
|
||||
const pluginRoot = await tempDirs.make("openclaw-plugin-");
|
||||
const outsideDir = await tempDirs.make("openclaw-outside-");
|
||||
const outsideSkills = path.join(outsideDir, "skills");
|
||||
const linkPath = path.join(pluginRoot, "skills-link");
|
||||
await fs.mkdir(outsideSkills, { recursive: true });
|
||||
await fs.symlink(
|
||||
@@ -153,12 +148,22 @@ describe("resolvePluginSkillDirs", () => {
|
||||
process.platform === "win32" ? ("junction" as const) : ("dir" as const),
|
||||
);
|
||||
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue(
|
||||
createSinglePluginRegistry({
|
||||
pluginRoot,
|
||||
skills: ["./skills-link"],
|
||||
}),
|
||||
);
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "helper",
|
||||
name: "Helper",
|
||||
channels: [],
|
||||
providers: [],
|
||||
skills: ["./skills-link"],
|
||||
origin: "workspace",
|
||||
rootDir: pluginRoot,
|
||||
source: pluginRoot,
|
||||
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
|
||||
},
|
||||
],
|
||||
} satisfies PluginManifestRegistry);
|
||||
|
||||
const dirs = resolvePluginSkillDirs({
|
||||
workspaceDir,
|
||||
|
||||
@@ -1,54 +1,46 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const noop = () => {};
|
||||
const MAIN_REQUESTER_SESSION_KEY = "agent:main:main";
|
||||
const MAIN_REQUESTER_DISPLAY_KEY = "main";
|
||||
|
||||
type LifecycleData = {
|
||||
phase?: string;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
aborted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
type LifecycleEvent = {
|
||||
stream?: string;
|
||||
runId: string;
|
||||
data?: LifecycleData;
|
||||
};
|
||||
|
||||
let lifecycleHandler: ((evt: LifecycleEvent) => void) | undefined;
|
||||
const callGatewayMock = vi.fn(async (request: unknown) => {
|
||||
const method = (request as { method?: string }).method;
|
||||
if (method === "agent.wait") {
|
||||
// Keep wait unresolved from the RPC path so lifecycle fallback logic is exercised.
|
||||
return { status: "pending" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const onAgentEventMock = vi.fn((handler: typeof lifecycleHandler) => {
|
||||
lifecycleHandler = handler;
|
||||
return noop;
|
||||
});
|
||||
const loadConfigMock = vi.fn(() => ({
|
||||
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
|
||||
}));
|
||||
const loadRegistryMock = vi.fn(() => new Map());
|
||||
const saveRegistryMock = vi.fn(() => {});
|
||||
const announceSpy = vi.fn(async () => true);
|
||||
let lifecycleHandler:
|
||||
| ((evt: {
|
||||
stream?: string;
|
||||
runId: string;
|
||||
data?: {
|
||||
phase?: string;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
aborted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}) => void)
|
||||
| undefined;
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: callGatewayMock,
|
||||
callGateway: vi.fn(async (request: unknown) => {
|
||||
const method = (request as { method?: string }).method;
|
||||
if (method === "agent.wait") {
|
||||
// Keep wait unresolved from the RPC path so lifecycle fallback logic is exercised.
|
||||
return { status: "pending" };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/agent-events.js", () => ({
|
||||
onAgentEvent: onAgentEventMock,
|
||||
onAgentEvent: vi.fn((handler: typeof lifecycleHandler) => {
|
||||
lifecycleHandler = handler;
|
||||
return noop;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
loadConfig: vi.fn(() => ({
|
||||
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
|
||||
})),
|
||||
}));
|
||||
|
||||
const announceSpy = vi.fn(async () => true);
|
||||
vi.mock("./subagent-announce.js", () => ({
|
||||
runSubagentAnnounceFlow: announceSpy,
|
||||
}));
|
||||
@@ -58,8 +50,8 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-registry.store.js", () => ({
|
||||
loadSubagentRegistryFromDisk: loadRegistryMock,
|
||||
saveSubagentRegistryToDisk: saveRegistryMock,
|
||||
loadSubagentRegistryFromDisk: vi.fn(() => new Map()),
|
||||
saveSubagentRegistryToDisk: vi.fn(() => {}),
|
||||
}));
|
||||
|
||||
describe("subagent registry lifecycle error grace", () => {
|
||||
@@ -85,41 +77,21 @@ describe("subagent registry lifecycle error grace", () => {
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
function registerCompletionRun(runId: string, childSuffix: string, task: string) {
|
||||
it("ignores transient lifecycle errors when run retries and then ends successfully", async () => {
|
||||
mod.registerSubagentRun({
|
||||
runId,
|
||||
childSessionKey: `agent:main:subagent:${childSuffix}`,
|
||||
requesterSessionKey: MAIN_REQUESTER_SESSION_KEY,
|
||||
requesterDisplayKey: MAIN_REQUESTER_DISPLAY_KEY,
|
||||
task,
|
||||
runId: "run-transient-error",
|
||||
childSessionKey: "agent:main:subagent:transient-error",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "transient error test",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
}
|
||||
|
||||
function emitLifecycleEvent(runId: string, data: LifecycleData) {
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function readFirstAnnounceOutcome() {
|
||||
const announceCalls = announceSpy.mock.calls as unknown as Array<Array<unknown>>;
|
||||
const first = (announceCalls[0]?.[0] ?? {}) as {
|
||||
outcome?: { status?: string; error?: string };
|
||||
};
|
||||
return first.outcome;
|
||||
}
|
||||
|
||||
it("ignores transient lifecycle errors when run retries and then ends successfully", async () => {
|
||||
registerCompletionRun("run-transient-error", "transient-error", "transient error test");
|
||||
|
||||
emitLifecycleEvent("run-transient-error", {
|
||||
phase: "error",
|
||||
error: "rate limit",
|
||||
endedAt: 1_000,
|
||||
runId: "run-transient-error",
|
||||
data: { phase: "error", error: "rate limit", endedAt: 1_000 },
|
||||
});
|
||||
await flushAsync();
|
||||
expect(announceSpy).not.toHaveBeenCalled();
|
||||
@@ -127,26 +99,46 @@ describe("subagent registry lifecycle error grace", () => {
|
||||
await vi.advanceTimersByTimeAsync(14_999);
|
||||
expect(announceSpy).not.toHaveBeenCalled();
|
||||
|
||||
emitLifecycleEvent("run-transient-error", { phase: "start", startedAt: 1_050 });
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-transient-error",
|
||||
data: { phase: "start", startedAt: 1_050 },
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(announceSpy).not.toHaveBeenCalled();
|
||||
|
||||
emitLifecycleEvent("run-transient-error", { phase: "end", endedAt: 1_250 });
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-transient-error",
|
||||
data: { phase: "end", endedAt: 1_250 },
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(announceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(readFirstAnnounceOutcome()?.status).toBe("ok");
|
||||
const announceCalls = announceSpy.mock.calls as unknown as Array<Array<unknown>>;
|
||||
const first = (announceCalls[0]?.[0] ?? {}) as {
|
||||
outcome?: { status?: string; error?: string };
|
||||
};
|
||||
expect(first.outcome?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("announces error when lifecycle error remains terminal after grace window", async () => {
|
||||
registerCompletionRun("run-terminal-error", "terminal-error", "terminal error test");
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-terminal-error",
|
||||
childSessionKey: "agent:main:subagent:terminal-error",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "terminal error test",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
emitLifecycleEvent("run-terminal-error", {
|
||||
phase: "error",
|
||||
error: "fatal failure",
|
||||
endedAt: 2_000,
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-terminal-error",
|
||||
data: { phase: "error", error: "fatal failure", endedAt: 2_000 },
|
||||
});
|
||||
await flushAsync();
|
||||
expect(announceSpy).not.toHaveBeenCalled();
|
||||
@@ -155,7 +147,11 @@ describe("subagent registry lifecycle error grace", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(announceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(readFirstAnnounceOutcome()?.status).toBe("error");
|
||||
expect(readFirstAnnounceOutcome()?.error).toBe("fatal failure");
|
||||
const announceCalls = announceSpy.mock.calls as unknown as Array<Array<unknown>>;
|
||||
const first = (announceCalls[0]?.[0] ?? {}) as {
|
||||
outcome?: { status?: string; error?: string };
|
||||
};
|
||||
expect(first.outcome?.status).toBe("error");
|
||||
expect(first.outcome?.error).toBe("fatal failure");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,8 +84,6 @@ vi.mock("./subagent-registry.store.js", () => ({
|
||||
describe("subagent registry steer restarts", () => {
|
||||
let mod: typeof import("./subagent-registry.js");
|
||||
type RegisterSubagentRunInput = Parameters<typeof mod.registerSubagentRun>[0];
|
||||
const MAIN_REQUESTER_SESSION_KEY = "agent:main:main";
|
||||
const MAIN_REQUESTER_DISPLAY_KEY = "main";
|
||||
|
||||
beforeAll(async () => {
|
||||
mod = await import("./subagent-registry.js");
|
||||
@@ -137,62 +135,20 @@ describe("subagent registry steer restarts", () => {
|
||||
task: string,
|
||||
options: Partial<Pick<RegisterSubagentRunInput, "spawnMode">> = {},
|
||||
): void => {
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId,
|
||||
childSessionKey,
|
||||
task,
|
||||
expectsCompletionMessage: true,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
to: "channel:123",
|
||||
accountId: "work",
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
const registerRun = (
|
||||
params: {
|
||||
runId: string;
|
||||
childSessionKey: string;
|
||||
task: string;
|
||||
requesterSessionKey?: string;
|
||||
requesterDisplayKey?: string;
|
||||
} & Partial<
|
||||
Pick<RegisterSubagentRunInput, "spawnMode" | "requesterOrigin" | "expectsCompletionMessage">
|
||||
>,
|
||||
): void => {
|
||||
mod.registerSubagentRun({
|
||||
runId: params.runId,
|
||||
childSessionKey: params.childSessionKey,
|
||||
requesterSessionKey: params.requesterSessionKey ?? MAIN_REQUESTER_SESSION_KEY,
|
||||
requesterDisplayKey: params.requesterDisplayKey ?? MAIN_REQUESTER_DISPLAY_KEY,
|
||||
requesterOrigin: params.requesterOrigin,
|
||||
task: params.task,
|
||||
task,
|
||||
cleanup: "keep",
|
||||
spawnMode: params.spawnMode,
|
||||
expectsCompletionMessage: params.expectsCompletionMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const listMainRuns = () => mod.listSubagentRunsForRequester(MAIN_REQUESTER_SESSION_KEY);
|
||||
|
||||
const emitLifecycleEnd = (
|
||||
runId: string,
|
||||
data: {
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
aborted?: boolean;
|
||||
error?: string;
|
||||
} = {},
|
||||
) => {
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId,
|
||||
data: {
|
||||
phase: "end",
|
||||
...data,
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -205,19 +161,26 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
|
||||
it("suppresses announce for interrupted runs and only announces the replacement run", async () => {
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-old",
|
||||
childSessionKey: "agent:main:subagent:steer",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "initial task",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
const previous = listMainRuns()[0];
|
||||
const previous = mod.listSubagentRunsForRequester("agent:main:main")[0];
|
||||
expect(previous?.runId).toBe("run-old");
|
||||
|
||||
const marked = mod.markSubagentRunForSteerRestart("run-old");
|
||||
expect(marked).toBe(true);
|
||||
|
||||
emitLifecycleEnd("run-old");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-old",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
await flushAnnounce();
|
||||
expect(announceSpy).not.toHaveBeenCalled();
|
||||
@@ -230,11 +193,15 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
expect(replaced).toBe(true);
|
||||
|
||||
const runs = listMainRuns();
|
||||
const runs = mod.listSubagentRunsForRequester("agent:main:main");
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].runId).toBe("run-new");
|
||||
|
||||
emitLifecycleEnd("run-new");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-new",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
await flushAnnounce();
|
||||
expect(announceSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -261,7 +228,11 @@ describe("subagent registry steer restarts", () => {
|
||||
"completion-mode task",
|
||||
);
|
||||
|
||||
emitLifecycleEnd("run-completion-delayed");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-completion-delayed",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
await flushAnnounce();
|
||||
expect(runSubagentEndedHookMock).not.toHaveBeenCalled();
|
||||
@@ -278,7 +249,7 @@ describe("subagent registry steer restarts", () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runId: "run-completion-delayed",
|
||||
requesterSessionKey: MAIN_REQUESTER_SESSION_KEY,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -294,7 +265,11 @@ describe("subagent registry steer restarts", () => {
|
||||
{ spawnMode: "session" },
|
||||
);
|
||||
|
||||
emitLifecycleEnd("run-persistent-session");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-persistent-session",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
await flushAnnounce();
|
||||
expect(runSubagentEndedHookMock).not.toHaveBeenCalled();
|
||||
@@ -303,7 +278,7 @@ describe("subagent registry steer restarts", () => {
|
||||
await flushAnnounce();
|
||||
|
||||
expect(runSubagentEndedHookMock).not.toHaveBeenCalled();
|
||||
const run = listMainRuns()[0];
|
||||
const run = mod.listSubagentRunsForRequester("agent:main:main")[0];
|
||||
expect(run?.runId).toBe("run-persistent-session");
|
||||
expect(run?.cleanupCompletedAt).toBeTypeOf("number");
|
||||
expect(run?.endedHookEmittedAt).toBeUndefined();
|
||||
@@ -311,13 +286,16 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
|
||||
it("clears announce retry state when replacing after steer restart", () => {
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-retry-reset-old",
|
||||
childSessionKey: "agent:main:subagent:retry-reset",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "retry reset",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
const previous = listMainRuns()[0];
|
||||
const previous = mod.listSubagentRunsForRequester("agent:main:main")[0];
|
||||
expect(previous?.runId).toBe("run-retry-reset-old");
|
||||
if (previous) {
|
||||
previous.announceRetryCount = 2;
|
||||
@@ -331,7 +309,7 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
expect(replaced).toBe(true);
|
||||
|
||||
const runs = listMainRuns();
|
||||
const runs = mod.listSubagentRunsForRequester("agent:main:main");
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].runId).toBe("run-retry-reset-new");
|
||||
expect(runs[0].announceRetryCount).toBeUndefined();
|
||||
@@ -339,13 +317,16 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
|
||||
it("clears terminal lifecycle state when replacing after steer restart", async () => {
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-terminal-state-old",
|
||||
childSessionKey: "agent:main:subagent:terminal-state",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "terminal state",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
const previous = listMainRuns()[0];
|
||||
const previous = mod.listSubagentRunsForRequester("agent:main:main")[0];
|
||||
expect(previous?.runId).toBe("run-terminal-state-old");
|
||||
if (previous) {
|
||||
previous.endedHookEmittedAt = Date.now();
|
||||
@@ -361,13 +342,17 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
expect(replaced).toBe(true);
|
||||
|
||||
const runs = listMainRuns();
|
||||
const runs = mod.listSubagentRunsForRequester("agent:main:main");
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].runId).toBe("run-terminal-state-new");
|
||||
expect(runs[0].endedHookEmittedAt).toBeUndefined();
|
||||
expect(runs[0].endedReason).toBeUndefined();
|
||||
|
||||
emitLifecycleEnd("run-terminal-state-new");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-terminal-state-new",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
await flushAnnounce();
|
||||
expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1);
|
||||
@@ -382,15 +367,22 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
|
||||
it("restores announce for a finished run when steer replacement dispatch fails", async () => {
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-failed-restart",
|
||||
childSessionKey: "agent:main:subagent:failed-restart",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "initial task",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
expect(mod.markSubagentRunForSteerRestart("run-failed-restart")).toBe(true);
|
||||
|
||||
emitLifecycleEnd("run-failed-restart");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-failed-restart",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
await flushAnnounce();
|
||||
expect(announceSpy).not.toHaveBeenCalled();
|
||||
@@ -406,10 +398,13 @@ describe("subagent registry steer restarts", () => {
|
||||
it("marks killed runs terminated and inactive", async () => {
|
||||
const childSessionKey = "agent:main:subagent:killed";
|
||||
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-killed",
|
||||
childSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "kill me",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(true);
|
||||
@@ -420,7 +415,7 @@ describe("subagent registry steer restarts", () => {
|
||||
expect(updated).toBe(1);
|
||||
expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(false);
|
||||
|
||||
const run = listMainRuns()[0];
|
||||
const run = mod.listSubagentRunsForRequester("agent:main:main")[0];
|
||||
expect(run?.outcome).toEqual({ status: "error", error: "manual kill" });
|
||||
expect(run?.cleanupHandled).toBe(true);
|
||||
expect(typeof run?.cleanupCompletedAt).toBe("number");
|
||||
@@ -439,7 +434,7 @@ describe("subagent registry steer restarts", () => {
|
||||
{
|
||||
runId: "run-killed",
|
||||
childSessionKey,
|
||||
requesterSessionKey: MAIN_REQUESTER_SESSION_KEY,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -455,23 +450,35 @@ describe("subagent registry steer restarts", () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-parent",
|
||||
childSessionKey: "agent:main:subagent:parent",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "parent task",
|
||||
cleanup: "keep",
|
||||
});
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-child",
|
||||
childSessionKey: "agent:main:subagent:parent:subagent:child",
|
||||
requesterSessionKey: "agent:main:subagent:parent",
|
||||
requesterDisplayKey: "parent",
|
||||
task: "child task",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
emitLifecycleEnd("run-parent");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-parent",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
await flushAnnounce();
|
||||
|
||||
emitLifecycleEnd("run-child");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-child",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
await flushAnnounce();
|
||||
|
||||
const childRunIds = announceSpy.mock.calls.map(
|
||||
@@ -487,33 +494,43 @@ describe("subagent registry steer restarts", () => {
|
||||
try {
|
||||
announceSpy.mockResolvedValue(false);
|
||||
|
||||
registerCompletionModeRun(
|
||||
"run-completion-retry",
|
||||
"agent:main:subagent:completion",
|
||||
"completion retry",
|
||||
);
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-completion-retry",
|
||||
childSessionKey: "agent:main:subagent:completion",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "completion retry",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
emitLifecycleEnd("run-completion-retry");
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-completion-retry",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(announceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(listMainRuns()[0]?.announceRetryCount).toBe(1);
|
||||
expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(announceSpy).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(announceSpy).toHaveBeenCalledTimes(2);
|
||||
expect(listMainRuns()[0]?.announceRetryCount).toBe(2);
|
||||
expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_999);
|
||||
expect(announceSpy).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(announceSpy).toHaveBeenCalledTimes(3);
|
||||
expect(listMainRuns()[0]?.announceRetryCount).toBe(3);
|
||||
expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_001);
|
||||
expect(announceSpy).toHaveBeenCalledTimes(3);
|
||||
expect(listMainRuns()[0]?.cleanupCompletedAt).toBeTypeOf("number");
|
||||
expect(
|
||||
mod.listSubagentRunsForRequester("agent:main:main")[0]?.cleanupCompletedAt,
|
||||
).toBeTypeOf("number");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -523,22 +540,32 @@ describe("subagent registry steer restarts", () => {
|
||||
it("keeps completion cleanup pending while descendants are still active", async () => {
|
||||
announceSpy.mockResolvedValue(false);
|
||||
|
||||
registerCompletionModeRun(
|
||||
"run-parent-expiry",
|
||||
"agent:main:subagent:parent-expiry",
|
||||
"parent completion expiry",
|
||||
);
|
||||
registerRun({
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-parent-expiry",
|
||||
childSessionKey: "agent:main:subagent:parent-expiry",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "parent completion expiry",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-child-active",
|
||||
childSessionKey: "agent:main:subagent:parent-expiry:subagent:child-active",
|
||||
requesterSessionKey: "agent:main:subagent:parent-expiry",
|
||||
requesterDisplayKey: "parent-expiry",
|
||||
task: "child still running",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
emitLifecycleEnd("run-parent-expiry", {
|
||||
startedAt: Date.now() - 7 * 60_000,
|
||||
endedAt: Date.now() - 6 * 60_000,
|
||||
lifecycleHandler?.({
|
||||
stream: "lifecycle",
|
||||
runId: "run-parent-expiry",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt: Date.now() - 7 * 60_000,
|
||||
endedAt: Date.now() - 6 * 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
await flushAnnounce();
|
||||
@@ -549,7 +576,7 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
expect(parentHookCall).toBeUndefined();
|
||||
const parent = mod
|
||||
.listSubagentRunsForRequester(MAIN_REQUESTER_SESSION_KEY)
|
||||
.listSubagentRunsForRequester("agent:main:main")
|
||||
.find((entry) => entry.runId === "run-parent-expiry");
|
||||
expect(parent?.cleanupCompletedAt).toBeUndefined();
|
||||
expect(parent?.cleanupHandled).toBe(false);
|
||||
|
||||
@@ -60,7 +60,6 @@ const BrowserActSchema = Type.Object({
|
||||
slowly: Type.Optional(Type.Boolean()),
|
||||
// press
|
||||
key: Type.Optional(Type.String()),
|
||||
delayMs: Type.Optional(Type.Number()),
|
||||
// drag
|
||||
startRef: Type.Optional(Type.String()),
|
||||
endRef: Type.Optional(Type.String()),
|
||||
@@ -73,11 +72,7 @@ const BrowserActSchema = Type.Object({
|
||||
height: Type.Optional(Type.Number()),
|
||||
// wait
|
||||
timeMs: Type.Optional(Type.Number()),
|
||||
selector: Type.Optional(Type.String()),
|
||||
url: Type.Optional(Type.String()),
|
||||
loadState: Type.Optional(Type.String()),
|
||||
textGone: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
// evaluate
|
||||
fn: Type.Optional(Type.String()),
|
||||
});
|
||||
@@ -114,25 +109,5 @@ export const BrowserToolSchema = Type.Object({
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
accept: Type.Optional(Type.Boolean()),
|
||||
promptText: Type.Optional(Type.String()),
|
||||
// Legacy flattened act params (preferred: request={...})
|
||||
kind: Type.Optional(stringEnum(BROWSER_ACT_KINDS)),
|
||||
doubleClick: Type.Optional(Type.Boolean()),
|
||||
button: Type.Optional(Type.String()),
|
||||
modifiers: Type.Optional(Type.Array(Type.String())),
|
||||
text: Type.Optional(Type.String()),
|
||||
submit: Type.Optional(Type.Boolean()),
|
||||
slowly: Type.Optional(Type.Boolean()),
|
||||
key: Type.Optional(Type.String()),
|
||||
delayMs: Type.Optional(Type.Number()),
|
||||
startRef: Type.Optional(Type.String()),
|
||||
endRef: Type.Optional(Type.String()),
|
||||
values: Type.Optional(Type.Array(Type.String())),
|
||||
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
|
||||
width: Type.Optional(Type.Number()),
|
||||
height: Type.Optional(Type.Number()),
|
||||
timeMs: Type.Optional(Type.Number()),
|
||||
textGone: Type.Optional(Type.String()),
|
||||
loadState: Type.Optional(Type.String()),
|
||||
fn: Type.Optional(Type.String()),
|
||||
request: Type.Optional(BrowserActSchema),
|
||||
});
|
||||
|
||||
@@ -307,62 +307,6 @@ describe("browser tool url alias support", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser tool act compatibility", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("accepts flattened act params for backward compatibility", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "act",
|
||||
kind: "type",
|
||||
ref: "f1e3",
|
||||
text: "Test Title",
|
||||
targetId: "tab-1",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(browserActionsMocks.browserAct).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
kind: "type",
|
||||
ref: "f1e3",
|
||||
text: "Test Title",
|
||||
targetId: "tab-1",
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
expect.objectContaining({ profile: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers request payload when both request and flattened fields are present", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "act",
|
||||
kind: "click",
|
||||
ref: "legacy-ref",
|
||||
request: {
|
||||
kind: "press",
|
||||
key: "Enter",
|
||||
targetId: "tab-2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(browserActionsMocks.browserAct).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
{
|
||||
kind: "press",
|
||||
key: "Enter",
|
||||
targetId: "tab-2",
|
||||
},
|
||||
expect.objectContaining({ profile: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser tool snapshot labels", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -91,53 +91,6 @@ function readTargetUrlParam(params: Record<string, unknown>) {
|
||||
);
|
||||
}
|
||||
|
||||
const LEGACY_BROWSER_ACT_REQUEST_KEYS = [
|
||||
"targetId",
|
||||
"ref",
|
||||
"doubleClick",
|
||||
"button",
|
||||
"modifiers",
|
||||
"text",
|
||||
"submit",
|
||||
"slowly",
|
||||
"key",
|
||||
"delayMs",
|
||||
"startRef",
|
||||
"endRef",
|
||||
"values",
|
||||
"fields",
|
||||
"width",
|
||||
"height",
|
||||
"timeMs",
|
||||
"textGone",
|
||||
"selector",
|
||||
"url",
|
||||
"loadState",
|
||||
"fn",
|
||||
"timeoutMs",
|
||||
] as const;
|
||||
|
||||
function readActRequestParam(params: Record<string, unknown>) {
|
||||
const requestParam = params.request;
|
||||
if (requestParam && typeof requestParam === "object") {
|
||||
return requestParam as Parameters<typeof browserAct>[1];
|
||||
}
|
||||
|
||||
const kind = readStringParam(params, "kind");
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const request: Record<string, unknown> = { kind };
|
||||
for (const key of LEGACY_BROWSER_ACT_REQUEST_KEYS) {
|
||||
if (!Object.hasOwn(params, key)) {
|
||||
continue;
|
||||
}
|
||||
request[key] = params[key];
|
||||
}
|
||||
return request as Parameters<typeof browserAct>[1];
|
||||
}
|
||||
|
||||
type BrowserProxyFile = {
|
||||
path: string;
|
||||
base64: string;
|
||||
@@ -843,8 +796,8 @@ export function createBrowserTool(opts?: {
|
||||
);
|
||||
}
|
||||
case "act": {
|
||||
const request = readActRequestParam(params);
|
||||
if (!request) {
|
||||
const request = params.request as Record<string, unknown> | undefined;
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error("request required");
|
||||
}
|
||||
try {
|
||||
@@ -855,7 +808,7 @@ export function createBrowserTool(opts?: {
|
||||
profile,
|
||||
body: request,
|
||||
})
|
||||
: await browserAct(baseUrl, request, {
|
||||
: await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
|
||||
profile,
|
||||
});
|
||||
return jsonResult(result);
|
||||
|
||||
@@ -3,7 +3,23 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { getDefaultLocalRoots, loadWebMedia } from "../../web/media.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||
import { minimaxUnderstandImage } from "../minimax-vlm.js";
|
||||
import { getApiKeyForModel, requireApiKey, resolveEnvApiKey } from "../model-auth.js";
|
||||
import { runWithImageModelFallback } from "../model-fallback.js";
|
||||
import { resolveConfiguredModelRef } from "../model-selection.js";
|
||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
|
||||
import {
|
||||
createSandboxBridgeReadFile,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
type SandboxedBridgeMediaPathConfig,
|
||||
} from "../sandbox-media-paths.js";
|
||||
import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
|
||||
import type { ToolFsPolicy } from "../tool-fs-policy.js";
|
||||
import { normalizeWorkspaceDir } from "../workspace-dir.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import {
|
||||
coerceImageAssistantText,
|
||||
coerceImageModelConfig,
|
||||
@@ -11,22 +27,6 @@ import {
|
||||
type ImageModelConfig,
|
||||
resolveProviderVisionModelFromConfig,
|
||||
} from "./image-tool.helpers.js";
|
||||
import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js";
|
||||
import {
|
||||
createSandboxBridgeReadFile,
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
ensureOpenClawModelsJson,
|
||||
getApiKeyForModel,
|
||||
normalizeWorkspaceDir,
|
||||
requireApiKey,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
runWithImageModelFallback,
|
||||
type AnyAgentTool,
|
||||
type SandboxedBridgeMediaPathConfig,
|
||||
type SandboxFsBridge,
|
||||
type ToolFsPolicy,
|
||||
} from "./tool-runtime.helpers.js";
|
||||
|
||||
const DEFAULT_PROMPT = "Describe the image.";
|
||||
const ANTHROPIC_IMAGE_PRIMARY = "anthropic/claude-opus-4-6";
|
||||
@@ -50,6 +50,31 @@ function resolveImageToolMaxTokens(modelMaxTokens: number | undefined, requested
|
||||
return Math.min(requestedMaxTokens, modelMaxTokens);
|
||||
}
|
||||
|
||||
function resolveDefaultModelRef(cfg?: OpenClawConfig): {
|
||||
provider: string;
|
||||
model: string;
|
||||
} {
|
||||
if (cfg) {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
return { provider: resolved.provider, model: resolved.model };
|
||||
}
|
||||
return { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL };
|
||||
}
|
||||
|
||||
function hasAuthForProvider(params: { provider: string; agentDir: string }): boolean {
|
||||
if (resolveEnvApiKey(params.provider)?.apiKey) {
|
||||
return true;
|
||||
}
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
return listProfilesForProvider(store, params.provider).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective image model config for the `image` tool.
|
||||
*
|
||||
|
||||
@@ -40,58 +40,6 @@ function getActionEnum(properties: Record<string, unknown>) {
|
||||
return (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
|
||||
}
|
||||
|
||||
function createChannelPlugin(params: {
|
||||
id: string;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
blurb: string;
|
||||
actions: string[];
|
||||
supportsButtons?: boolean;
|
||||
messaging?: ChannelPlugin["messaging"];
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
id: params.id as ChannelPlugin["id"],
|
||||
meta: {
|
||||
id: params.id as ChannelPlugin["id"],
|
||||
label: params.label,
|
||||
selectionLabel: params.label,
|
||||
docsPath: params.docsPath,
|
||||
blurb: params.blurb,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
...(params.messaging ? { messaging: params.messaging } : {}),
|
||||
actions: {
|
||||
listActions: () => params.actions as never,
|
||||
...(params.supportsButtons ? { supportsButtons: () => true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeSend(params: {
|
||||
action: Record<string, unknown>;
|
||||
toolOptions?: Partial<Parameters<typeof createMessageTool>[0]>;
|
||||
}) {
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
...params.toolOptions,
|
||||
});
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
...params.action,
|
||||
});
|
||||
return mocks.runMessageAction.mock.calls[0]?.[0] as
|
||||
| {
|
||||
params?: Record<string, unknown>;
|
||||
sandboxRoot?: string;
|
||||
requesterSenderId?: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
describe("message tool agent routing", () => {
|
||||
it("derives agentId from the session key", async () => {
|
||||
mockSendResult();
|
||||
@@ -114,103 +62,141 @@ describe("message tool agent routing", () => {
|
||||
});
|
||||
|
||||
describe("message tool path passthrough", () => {
|
||||
it.each([
|
||||
{ field: "path", value: "~/Downloads/voice.ogg" },
|
||||
{ field: "filePath", value: "./tmp/note.m4a" },
|
||||
])("does not convert $field to media for send", async ({ field, value }) => {
|
||||
it("does not convert path to media for send", async () => {
|
||||
mockSendResult({ to: "telegram:123" });
|
||||
|
||||
const call = await executeSend({
|
||||
action: {
|
||||
target: "telegram:123",
|
||||
[field]: value,
|
||||
message: "",
|
||||
},
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
expect(call?.params?.[field]).toBe(value);
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "telegram:123",
|
||||
path: "~/Downloads/voice.ogg",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.params?.path).toBe("~/Downloads/voice.ogg");
|
||||
expect(call?.params?.media).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not convert filePath to media for send", async () => {
|
||||
mockSendResult({ to: "telegram:123" });
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "telegram:123",
|
||||
filePath: "./tmp/note.m4a",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.params?.filePath).toBe("./tmp/note.m4a");
|
||||
expect(call?.params?.media).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool schema scoping", () => {
|
||||
const telegramPlugin = createChannelPlugin({
|
||||
const telegramPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
actions: ["send", "react"],
|
||||
supportsButtons: true,
|
||||
});
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send", "react"] as const,
|
||||
supportsButtons: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
const discordPlugin = createChannelPlugin({
|
||||
const discordPlugin: ChannelPlugin = {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "Discord test plugin.",
|
||||
actions: ["send", "poll"],
|
||||
});
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "Discord test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send", "poll"] as const,
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
provider: "telegram",
|
||||
expectComponents: false,
|
||||
expectButtons: true,
|
||||
expectButtonStyle: true,
|
||||
expectedActions: ["send", "react", "poll"],
|
||||
},
|
||||
{
|
||||
provider: "discord",
|
||||
expectComponents: true,
|
||||
expectButtons: false,
|
||||
expectButtonStyle: false,
|
||||
expectedActions: ["send", "poll", "react"],
|
||||
},
|
||||
])(
|
||||
"scopes schema fields for $provider",
|
||||
({ provider, expectComponents, expectButtons, expectButtonStyle, expectedActions }) => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
||||
{ pluginId: "discord", source: "test", plugin: discordPlugin },
|
||||
]),
|
||||
);
|
||||
it("hides discord components when scoped to telegram", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
||||
{ pluginId: "discord", source: "test", plugin: discordPlugin },
|
||||
]),
|
||||
);
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: provider,
|
||||
});
|
||||
const properties = getToolProperties(tool);
|
||||
const actionEnum = getActionEnum(properties);
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: "telegram",
|
||||
});
|
||||
const properties = getToolProperties(tool);
|
||||
const actionEnum = getActionEnum(properties);
|
||||
|
||||
if (expectComponents) {
|
||||
expect(properties.components).toBeDefined();
|
||||
} else {
|
||||
expect(properties.components).toBeUndefined();
|
||||
}
|
||||
if (expectButtons) {
|
||||
expect(properties.buttons).toBeDefined();
|
||||
} else {
|
||||
expect(properties.buttons).toBeUndefined();
|
||||
}
|
||||
if (expectButtonStyle) {
|
||||
const buttonItemProps =
|
||||
(
|
||||
properties.buttons as {
|
||||
items?: { items?: { properties?: Record<string, unknown> } };
|
||||
}
|
||||
)?.items?.items?.properties ?? {};
|
||||
expect(buttonItemProps.style).toBeDefined();
|
||||
}
|
||||
for (const action of expectedActions) {
|
||||
expect(actionEnum).toContain(action);
|
||||
}
|
||||
},
|
||||
);
|
||||
expect(properties.components).toBeUndefined();
|
||||
expect(properties.buttons).toBeDefined();
|
||||
const buttonItemProps =
|
||||
(
|
||||
properties.buttons as {
|
||||
items?: { items?: { properties?: Record<string, unknown> } };
|
||||
}
|
||||
)?.items?.items?.properties ?? {};
|
||||
expect(buttonItemProps.style).toBeDefined();
|
||||
expect(actionEnum).toContain("send");
|
||||
expect(actionEnum).toContain("react");
|
||||
// Other channels' actions are included so isolated/cron agents can use them
|
||||
expect(actionEnum).toContain("poll");
|
||||
});
|
||||
|
||||
it("shows discord components when scoped to discord", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
||||
{ pluginId: "discord", source: "test", plugin: discordPlugin },
|
||||
]),
|
||||
);
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: "discord",
|
||||
});
|
||||
const properties = getToolProperties(tool);
|
||||
const actionEnum = getActionEnum(properties);
|
||||
|
||||
expect(properties.components).toBeDefined();
|
||||
expect(properties.buttons).toBeUndefined();
|
||||
expect(actionEnum).toContain("send");
|
||||
expect(actionEnum).toContain("poll");
|
||||
// Other channels' actions are included so isolated/cron agents can use them
|
||||
expect(actionEnum).toContain("react");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool description", () => {
|
||||
@@ -218,12 +204,20 @@ describe("message tool description", () => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
const bluebubblesPlugin = createChannelPlugin({
|
||||
const bluebubblesPlugin: ChannelPlugin = {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
docsPath: "/channels/bluebubbles",
|
||||
blurb: "BlueBubbles test plugin.",
|
||||
actions: ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"],
|
||||
meta: {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
selectionLabel: "BlueBubbles",
|
||||
docsPath: "/channels/bluebubbles",
|
||||
blurb: "BlueBubbles test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => {
|
||||
const trimmed = raw.trim().replace(/^bluebubbles:/i, "");
|
||||
@@ -239,7 +233,11 @@ describe("message tool description", () => {
|
||||
return trimmed;
|
||||
},
|
||||
},
|
||||
});
|
||||
actions: {
|
||||
listActions: () =>
|
||||
["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"] as const,
|
||||
},
|
||||
};
|
||||
|
||||
it("hides BlueBubbles group actions for DM targets", () => {
|
||||
setActivePluginRegistry(
|
||||
@@ -259,21 +257,43 @@ describe("message tool description", () => {
|
||||
});
|
||||
|
||||
it("includes other configured channels when currentChannel is set", () => {
|
||||
const signalPlugin = createChannelPlugin({
|
||||
const signalPlugin: ChannelPlugin = {
|
||||
id: "signal",
|
||||
label: "Signal",
|
||||
docsPath: "/channels/signal",
|
||||
blurb: "Signal test plugin.",
|
||||
actions: ["send", "react"],
|
||||
});
|
||||
meta: {
|
||||
id: "signal",
|
||||
label: "Signal",
|
||||
selectionLabel: "Signal",
|
||||
docsPath: "/channels/signal",
|
||||
blurb: "Signal test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send", "react"] as const,
|
||||
},
|
||||
};
|
||||
|
||||
const telegramPluginFull = createChannelPlugin({
|
||||
const telegramPluginFull: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
actions: ["send", "react", "delete", "edit", "topic-create"],
|
||||
});
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send", "react", "delete", "edit", "topic-create"] as const,
|
||||
},
|
||||
};
|
||||
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
@@ -310,80 +330,103 @@ describe("message tool description", () => {
|
||||
});
|
||||
|
||||
describe("message tool reasoning tag sanitization", () => {
|
||||
it.each([
|
||||
{
|
||||
field: "text",
|
||||
input: "<think>internal reasoning</think>Hello!",
|
||||
expected: "Hello!",
|
||||
target: "signal:+15551234567",
|
||||
channel: "signal",
|
||||
},
|
||||
{
|
||||
field: "content",
|
||||
input: "<think>reasoning here</think>Reply text",
|
||||
expected: "Reply text",
|
||||
target: "discord:123",
|
||||
channel: "discord",
|
||||
},
|
||||
{
|
||||
field: "text",
|
||||
input: "Normal message without any tags",
|
||||
expected: "Normal message without any tags",
|
||||
target: "signal:+15551234567",
|
||||
channel: "signal",
|
||||
},
|
||||
])(
|
||||
"sanitizes reasoning tags in $field before sending",
|
||||
async ({ channel, target, field, input, expected }) => {
|
||||
mockSendResult({ channel, to: target });
|
||||
it("strips <think> tags from text field before sending", async () => {
|
||||
mockSendResult({ channel: "signal", to: "signal:+15551234567" });
|
||||
|
||||
const call = await executeSend({
|
||||
action: {
|
||||
target,
|
||||
[field]: input,
|
||||
},
|
||||
});
|
||||
expect(call?.params?.[field]).toBe(expected);
|
||||
},
|
||||
);
|
||||
const tool = createMessageTool({ config: {} as never });
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "signal:+15551234567",
|
||||
text: "<think>internal reasoning</think>Hello!",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.params?.text).toBe("Hello!");
|
||||
});
|
||||
|
||||
it("strips <think> tags from content field before sending", async () => {
|
||||
mockSendResult({ channel: "discord", to: "discord:123" });
|
||||
|
||||
const tool = createMessageTool({ config: {} as never });
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "discord:123",
|
||||
content: "<think>reasoning here</think>Reply text",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.params?.content).toBe("Reply text");
|
||||
});
|
||||
|
||||
it("passes through text without reasoning tags unchanged", async () => {
|
||||
mockSendResult({ channel: "signal", to: "signal:+15551234567" });
|
||||
|
||||
const tool = createMessageTool({ config: {} as never });
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "signal:+15551234567",
|
||||
text: "Normal message without any tags",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.params?.text).toBe("Normal message without any tags");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool sandbox passthrough", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "forwards sandboxRoot to runMessageAction",
|
||||
toolOptions: { sandboxRoot: "/tmp/sandbox" },
|
||||
expected: "/tmp/sandbox",
|
||||
},
|
||||
{
|
||||
name: "omits sandboxRoot when not configured",
|
||||
toolOptions: {},
|
||||
expected: undefined,
|
||||
},
|
||||
])("$name", async ({ toolOptions, expected }) => {
|
||||
it("forwards sandboxRoot to runMessageAction", async () => {
|
||||
mockSendResult({ to: "telegram:123" });
|
||||
|
||||
const call = await executeSend({
|
||||
toolOptions,
|
||||
action: {
|
||||
target: "telegram:123",
|
||||
message: "",
|
||||
},
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
sandboxRoot: "/tmp/sandbox",
|
||||
});
|
||||
expect(call?.sandboxRoot).toBe(expected);
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "telegram:123",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.sandboxRoot).toBe("/tmp/sandbox");
|
||||
});
|
||||
|
||||
it("omits sandboxRoot when not configured", async () => {
|
||||
mockSendResult({ to: "telegram:123" });
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "telegram:123",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.sandboxRoot).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards trusted requesterSenderId to runMessageAction", async () => {
|
||||
mockSendResult({ to: "discord:123" });
|
||||
|
||||
const call = await executeSend({
|
||||
toolOptions: { requesterSenderId: "1234567890" },
|
||||
action: {
|
||||
target: "discord:123",
|
||||
message: "hi",
|
||||
},
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
requesterSenderId: "1234567890",
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "discord:123",
|
||||
message: "hi",
|
||||
});
|
||||
|
||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
||||
expect(call?.requesterSenderId).toBe("1234567890");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||
import { resolveEnvApiKey } from "../model-auth.js";
|
||||
import { resolveConfiguredModelRef } from "../model-selection.js";
|
||||
|
||||
export function resolveDefaultModelRef(cfg?: OpenClawConfig): { provider: string; model: string } {
|
||||
if (cfg) {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
return { provider: resolved.provider, model: resolved.model };
|
||||
}
|
||||
return { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL };
|
||||
}
|
||||
|
||||
export function hasAuthForProvider(params: { provider: string; agentDir: string }): boolean {
|
||||
if (resolveEnvApiKey(params.provider)?.apiKey) {
|
||||
return true;
|
||||
}
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
return listProfilesForProvider(store, params.provider).length > 0;
|
||||
}
|
||||
@@ -29,80 +29,6 @@ async function withTempAgentDir<T>(run: (agentDir: string) => Promise<T>): Promi
|
||||
}
|
||||
}
|
||||
|
||||
const ANTHROPIC_PDF_MODEL = "anthropic/claude-opus-4-6";
|
||||
const OPENAI_PDF_MODEL = "openai/gpt-5-mini";
|
||||
const FAKE_PDF_MEDIA = {
|
||||
kind: "document",
|
||||
buffer: Buffer.from("%PDF-1.4 fake"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "doc.pdf",
|
||||
} as const;
|
||||
|
||||
function resetAuthEnv() {
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
||||
vi.stubEnv("ANTHROPIC_OAUTH_TOKEN", "");
|
||||
vi.stubEnv("GEMINI_API_KEY", "");
|
||||
vi.stubEnv("GOOGLE_API_KEY", "");
|
||||
vi.stubEnv("MINIMAX_API_KEY", "");
|
||||
vi.stubEnv("ZAI_API_KEY", "");
|
||||
vi.stubEnv("Z_AI_API_KEY", "");
|
||||
vi.stubEnv("COPILOT_GITHUB_TOKEN", "");
|
||||
vi.stubEnv("GH_TOKEN", "");
|
||||
vi.stubEnv("GITHUB_TOKEN", "");
|
||||
}
|
||||
|
||||
function withDefaultModel(primary: string): OpenClawConfig {
|
||||
return {
|
||||
agents: { defaults: { model: { primary } } },
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function withPdfModel(primary: string): OpenClawConfig {
|
||||
return {
|
||||
agents: { defaults: { pdfModel: { primary } } },
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function stubPdfToolInfra(
|
||||
agentDir: string,
|
||||
params?: {
|
||||
provider?: string;
|
||||
input?: string[];
|
||||
modelFound?: boolean;
|
||||
},
|
||||
) {
|
||||
const webMedia = await import("../../web/media.js");
|
||||
const loadSpy = vi.spyOn(webMedia, "loadWebMediaRaw").mockResolvedValue(FAKE_PDF_MEDIA as never);
|
||||
|
||||
const modelDiscovery = await import("../pi-model-discovery.js");
|
||||
vi.spyOn(modelDiscovery, "discoverAuthStorage").mockReturnValue({
|
||||
setRuntimeApiKey: vi.fn(),
|
||||
} as never);
|
||||
const find =
|
||||
params?.modelFound === false
|
||||
? () => null
|
||||
: () =>
|
||||
({
|
||||
provider: params?.provider ?? "anthropic",
|
||||
maxTokens: 8192,
|
||||
input: params?.input ?? ["text", "document"],
|
||||
}) as never;
|
||||
vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue({ find } as never);
|
||||
|
||||
const modelsConfig = await import("../models-config.js");
|
||||
vi.spyOn(modelsConfig, "ensureOpenClawModelsJson").mockResolvedValue({
|
||||
agentDir,
|
||||
wrote: false,
|
||||
});
|
||||
|
||||
const modelAuth = await import("../model-auth.js");
|
||||
vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never);
|
||||
vi.spyOn(modelAuth, "requireApiKey").mockReturnValue("test-key");
|
||||
|
||||
return { loadSpy };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePageRange tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -184,7 +110,13 @@ describe("resolvePdfModelConfigForTool", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthEnv();
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
||||
vi.stubEnv("ANTHROPIC_OAUTH_TOKEN", "");
|
||||
vi.stubEnv("GOOGLE_API_KEY", "");
|
||||
vi.stubEnv("COPILOT_GITHUB_TOKEN", "");
|
||||
vi.stubEnv("GH_TOKEN", "");
|
||||
vi.stubEnv("GITHUB_TOKEN", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -237,20 +169,24 @@ describe("resolvePdfModelConfigForTool", () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
||||
vi.stubEnv("OPENAI_API_KEY", "openai-test");
|
||||
const cfg = withDefaultModel("openai/gpt-5.2");
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.2" } } },
|
||||
};
|
||||
const config = resolvePdfModelConfigForTool({ cfg, agentDir });
|
||||
expect(config).not.toBeNull();
|
||||
// Should prefer anthropic for native PDF
|
||||
expect(config?.primary).toBe(ANTHROPIC_PDF_MODEL);
|
||||
expect(config?.primary).toBe("anthropic/claude-opus-4-6");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses anthropic primary when provider is anthropic", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } },
|
||||
};
|
||||
const config = resolvePdfModelConfigForTool({ cfg, agentDir });
|
||||
expect(config?.primary).toBe(ANTHROPIC_PDF_MODEL);
|
||||
expect(config?.primary).toBe("anthropic/claude-opus-4-6");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -263,7 +199,13 @@ describe("createPdfTool", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthEnv();
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
||||
vi.stubEnv("ANTHROPIC_OAUTH_TOKEN", "");
|
||||
vi.stubEnv("GOOGLE_API_KEY", "");
|
||||
vi.stubEnv("COPILOT_GITHUB_TOKEN", "");
|
||||
vi.stubEnv("GH_TOKEN", "");
|
||||
vi.stubEnv("GITHUB_TOKEN", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -286,14 +228,22 @@ describe("createPdfTool", () => {
|
||||
});
|
||||
|
||||
it("throws when agentDir missing but explicit config present", () => {
|
||||
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
pdfModel: { primary: "anthropic/claude-opus-4-6" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
expect(() => createPdfTool({ config: cfg })).toThrow("requires agentDir");
|
||||
});
|
||||
|
||||
it("creates tool when auth is available", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } },
|
||||
};
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
expect(tool?.name).toBe("pdf");
|
||||
@@ -305,7 +255,9 @@ describe("createPdfTool", () => {
|
||||
it("rejects when no pdf input provided", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } },
|
||||
};
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
await expect(tool!.execute("t1", { prompt: "test" })).rejects.toThrow("pdf required");
|
||||
@@ -315,7 +267,9 @@ describe("createPdfTool", () => {
|
||||
it("rejects too many PDFs", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } },
|
||||
};
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
const manyPdfs = Array.from({ length: 15 }, (_, i) => `/tmp/doc${i}.pdf`);
|
||||
@@ -329,7 +283,9 @@ describe("createPdfTool", () => {
|
||||
it("rejects unsupported scheme references", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } },
|
||||
};
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
const result = await tool!.execute("t1", {
|
||||
@@ -344,8 +300,37 @@ describe("createPdfTool", () => {
|
||||
|
||||
it("deduplicates pdf inputs before loading", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
const { loadSpy } = await stubPdfToolInfra(agentDir, { modelFound: false });
|
||||
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
||||
const webMedia = await import("../../web/media.js");
|
||||
const loadSpy = vi.spyOn(webMedia, "loadWebMediaRaw").mockResolvedValue({
|
||||
kind: "document",
|
||||
buffer: Buffer.from("%PDF-1.4 fake"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "doc.pdf",
|
||||
} as never);
|
||||
|
||||
const modelDiscovery = await import("../pi-model-discovery.js");
|
||||
vi.spyOn(modelDiscovery, "discoverAuthStorage").mockReturnValue({
|
||||
setRuntimeApiKey: vi.fn(),
|
||||
} as never);
|
||||
vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue({ find: () => null } as never);
|
||||
|
||||
const modelsConfig = await import("../models-config.js");
|
||||
vi.spyOn(modelsConfig, "ensureOpenClawModelsJson").mockResolvedValue({
|
||||
agentDir,
|
||||
wrote: false,
|
||||
});
|
||||
|
||||
const modelAuth = await import("../model-auth.js");
|
||||
vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never);
|
||||
vi.spyOn(modelAuth, "requireApiKey").mockReturnValue("test-key");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
pdfModel: { primary: "anthropic/claude-opus-4-6" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
|
||||
@@ -363,7 +348,36 @@ describe("createPdfTool", () => {
|
||||
|
||||
it("uses native PDF path without eager extraction", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] });
|
||||
const webMedia = await import("../../web/media.js");
|
||||
vi.spyOn(webMedia, "loadWebMediaRaw").mockResolvedValue({
|
||||
kind: "document",
|
||||
buffer: Buffer.from("%PDF-1.4 fake"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "doc.pdf",
|
||||
} as never);
|
||||
|
||||
const modelDiscovery = await import("../pi-model-discovery.js");
|
||||
vi.spyOn(modelDiscovery, "discoverAuthStorage").mockReturnValue({
|
||||
setRuntimeApiKey: vi.fn(),
|
||||
} as never);
|
||||
vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue({
|
||||
find: () =>
|
||||
({
|
||||
provider: "anthropic",
|
||||
maxTokens: 8192,
|
||||
input: ["text", "document"],
|
||||
}) as never,
|
||||
} as never);
|
||||
|
||||
const modelsConfig = await import("../models-config.js");
|
||||
vi.spyOn(modelsConfig, "ensureOpenClawModelsJson").mockResolvedValue({
|
||||
agentDir,
|
||||
wrote: false,
|
||||
});
|
||||
|
||||
const modelAuth = await import("../model-auth.js");
|
||||
vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never);
|
||||
vi.spyOn(modelAuth, "requireApiKey").mockReturnValue("test-key");
|
||||
|
||||
const nativeProviders = await import("./pdf-native-providers.js");
|
||||
vi.spyOn(nativeProviders, "anthropicAnalyzePdf").mockResolvedValue("native summary");
|
||||
@@ -371,7 +385,14 @@ describe("createPdfTool", () => {
|
||||
const extractModule = await import("../../media/pdf-extract.js");
|
||||
const extractSpy = vi.spyOn(extractModule, "extractPdfContent");
|
||||
|
||||
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
pdfModel: { primary: "anthropic/claude-opus-4-6" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
|
||||
@@ -383,15 +404,52 @@ describe("createPdfTool", () => {
|
||||
expect(extractSpy).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
content: [{ type: "text", text: "native summary" }],
|
||||
details: { native: true, model: ANTHROPIC_PDF_MODEL },
|
||||
details: { native: true, model: "anthropic/claude-opus-4-6" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects pages parameter for native PDF providers", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] });
|
||||
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
||||
const webMedia = await import("../../web/media.js");
|
||||
vi.spyOn(webMedia, "loadWebMediaRaw").mockResolvedValue({
|
||||
kind: "document",
|
||||
buffer: Buffer.from("%PDF-1.4 fake"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "doc.pdf",
|
||||
} as never);
|
||||
|
||||
const modelDiscovery = await import("../pi-model-discovery.js");
|
||||
vi.spyOn(modelDiscovery, "discoverAuthStorage").mockReturnValue({
|
||||
setRuntimeApiKey: vi.fn(),
|
||||
} as never);
|
||||
vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue({
|
||||
find: () =>
|
||||
({
|
||||
provider: "anthropic",
|
||||
maxTokens: 8192,
|
||||
input: ["text", "document"],
|
||||
}) as never,
|
||||
} as never);
|
||||
|
||||
const modelsConfig = await import("../models-config.js");
|
||||
vi.spyOn(modelsConfig, "ensureOpenClawModelsJson").mockResolvedValue({
|
||||
agentDir,
|
||||
wrote: false,
|
||||
});
|
||||
|
||||
const modelAuth = await import("../model-auth.js");
|
||||
vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never);
|
||||
vi.spyOn(modelAuth, "requireApiKey").mockReturnValue("test-key");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
pdfModel: { primary: "anthropic/claude-opus-4-6" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
|
||||
@@ -407,7 +465,36 @@ describe("createPdfTool", () => {
|
||||
|
||||
it("uses extraction fallback for non-native models", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
await stubPdfToolInfra(agentDir, { provider: "openai", input: ["text"] });
|
||||
const webMedia = await import("../../web/media.js");
|
||||
vi.spyOn(webMedia, "loadWebMediaRaw").mockResolvedValue({
|
||||
kind: "document",
|
||||
buffer: Buffer.from("%PDF-1.4 fake"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "doc.pdf",
|
||||
} as never);
|
||||
|
||||
const modelDiscovery = await import("../pi-model-discovery.js");
|
||||
vi.spyOn(modelDiscovery, "discoverAuthStorage").mockReturnValue({
|
||||
setRuntimeApiKey: vi.fn(),
|
||||
} as never);
|
||||
vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue({
|
||||
find: () =>
|
||||
({
|
||||
provider: "openai",
|
||||
maxTokens: 8192,
|
||||
input: ["text"],
|
||||
}) as never,
|
||||
} as never);
|
||||
|
||||
const modelsConfig = await import("../models-config.js");
|
||||
vi.spyOn(modelsConfig, "ensureOpenClawModelsJson").mockResolvedValue({
|
||||
agentDir,
|
||||
wrote: false,
|
||||
});
|
||||
|
||||
const modelAuth = await import("../model-auth.js");
|
||||
vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never);
|
||||
vi.spyOn(modelAuth, "requireApiKey").mockReturnValue("test-key");
|
||||
|
||||
const extractModule = await import("../../media/pdf-extract.js");
|
||||
const extractSpy = vi.spyOn(extractModule, "extractPdfContent").mockResolvedValue({
|
||||
@@ -422,7 +509,13 @@ describe("createPdfTool", () => {
|
||||
content: [{ type: "text", text: "fallback summary" }],
|
||||
} as never);
|
||||
|
||||
const cfg = withPdfModel(OPENAI_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
pdfModel: { primary: "openai/gpt-5-mini" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
@@ -435,7 +528,7 @@ describe("createPdfTool", () => {
|
||||
expect(extractSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
content: [{ type: "text", text: "fallback summary" }],
|
||||
details: { native: false, model: OPENAI_PDF_MODEL },
|
||||
details: { native: false, model: "openai/gpt-5-mini" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -443,7 +536,9 @@ describe("createPdfTool", () => {
|
||||
it("tool parameters have correct schema shape", async () => {
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } },
|
||||
};
|
||||
const tool = createPdfTool({ config: cfg, agentDir });
|
||||
expect(tool).not.toBeNull();
|
||||
const schema = tool!.parameters;
|
||||
|
||||
@@ -4,12 +4,27 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { extractPdfContent, type PdfExtractedContent } from "../../media/pdf-extract.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { getDefaultLocalRoots, loadWebMediaRaw } from "../../web/media.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||
import { getApiKeyForModel, requireApiKey, resolveEnvApiKey } from "../model-auth.js";
|
||||
import { runWithImageModelFallback } from "../model-fallback.js";
|
||||
import { resolveConfiguredModelRef } from "../model-selection.js";
|
||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
|
||||
import {
|
||||
createSandboxBridgeReadFile,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
type SandboxedBridgeMediaPathConfig,
|
||||
} from "../sandbox-media-paths.js";
|
||||
import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
|
||||
import type { ToolFsPolicy } from "../tool-fs-policy.js";
|
||||
import { normalizeWorkspaceDir } from "../workspace-dir.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import {
|
||||
coerceImageModelConfig,
|
||||
type ImageModelConfig,
|
||||
resolveProviderVisionModelFromConfig,
|
||||
} from "./image-tool.helpers.js";
|
||||
import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js";
|
||||
import { anthropicAnalyzePdf, geminiAnalyzePdf } from "./pdf-native-providers.js";
|
||||
import {
|
||||
coercePdfAssistantText,
|
||||
@@ -18,21 +33,6 @@ import {
|
||||
providerSupportsNativePdf,
|
||||
resolvePdfToolMaxTokens,
|
||||
} from "./pdf-tool.helpers.js";
|
||||
import {
|
||||
createSandboxBridgeReadFile,
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
ensureOpenClawModelsJson,
|
||||
getApiKeyForModel,
|
||||
normalizeWorkspaceDir,
|
||||
requireApiKey,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
runWithImageModelFallback,
|
||||
type AnyAgentTool,
|
||||
type SandboxedBridgeMediaPathConfig,
|
||||
type SandboxFsBridge,
|
||||
type ToolFsPolicy,
|
||||
} from "./tool-runtime.helpers.js";
|
||||
|
||||
const DEFAULT_PROMPT = "Analyze this PDF document.";
|
||||
const DEFAULT_MAX_PDFS = 10;
|
||||
@@ -48,6 +48,26 @@ const PDF_MAX_PIXELS = 4_000_000;
|
||||
// Model resolution (mirrors image tool pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveDefaultModelRef(cfg?: OpenClawConfig): { provider: string; model: string } {
|
||||
if (cfg) {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
return { provider: resolved.provider, model: resolved.model };
|
||||
}
|
||||
return { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL };
|
||||
}
|
||||
|
||||
function hasAuthForProvider(params: { provider: string; agentDir: string }): boolean {
|
||||
if (resolveEnvApiKey(params.provider)?.apiKey) {
|
||||
return true;
|
||||
}
|
||||
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
|
||||
return listProfilesForProvider(store, params.provider).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective PDF model config.
|
||||
* Falls back to the image model config, then to provider-specific defaults.
|
||||
|
||||
@@ -31,19 +31,6 @@ describe("resolveMainSessionAlias", () => {
|
||||
scope: "per-sender",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses session.mainKey over any legacy routing sessions key", () => {
|
||||
const cfg = {
|
||||
session: { mainKey: " work ", scope: "per-sender" },
|
||||
routing: { sessions: { mainKey: "legacy-main" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveMainSessionAlias(cfg)).toEqual({
|
||||
mainKey: "work",
|
||||
alias: "work",
|
||||
scope: "per-sender",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("session key display/internal mapping", () => {
|
||||
|
||||
@@ -35,10 +35,6 @@ import { createSessionsSendTool } from "./sessions-send-tool.js";
|
||||
|
||||
let resolveAnnounceTarget: (typeof import("./sessions-announce-target.js"))["resolveAnnounceTarget"];
|
||||
let setActivePluginRegistry: (typeof import("../../plugins/runtime.js"))["setActivePluginRegistry"];
|
||||
const MAIN_AGENT_SESSION_KEY = "agent:main:main";
|
||||
const MAIN_AGENT_CHANNEL = "whatsapp";
|
||||
|
||||
type SessionsListResult = Awaited<ReturnType<ReturnType<typeof createSessionsListTool>["execute"]>>;
|
||||
|
||||
const installRegistry = async () => {
|
||||
setActivePluginRegistry(
|
||||
@@ -86,52 +82,6 @@ const installRegistry = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
function createMainSessionsListTool() {
|
||||
return createSessionsListTool({ agentSessionKey: MAIN_AGENT_SESSION_KEY });
|
||||
}
|
||||
|
||||
async function executeMainSessionsList() {
|
||||
return createMainSessionsListTool().execute("call1", {});
|
||||
}
|
||||
|
||||
function createMainSessionsSendTool() {
|
||||
return createSessionsSendTool({
|
||||
agentSessionKey: MAIN_AGENT_SESSION_KEY,
|
||||
agentChannel: MAIN_AGENT_CHANNEL,
|
||||
});
|
||||
}
|
||||
|
||||
function getFirstListedSession(result: SessionsListResult) {
|
||||
const details = result.details as
|
||||
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
|
||||
| undefined;
|
||||
return details?.sessions?.[0];
|
||||
}
|
||||
|
||||
function expectWorkerTranscriptPath(
|
||||
result: SessionsListResult,
|
||||
params: { containsPath: string; sessionId: string },
|
||||
) {
|
||||
const session = getFirstListedSession(result);
|
||||
expect(session).toMatchObject({ key: "agent:worker:main" });
|
||||
const transcriptPath = String(session?.transcriptPath ?? "");
|
||||
expect(path.normalize(transcriptPath)).toContain(path.normalize(params.containsPath));
|
||||
expect(transcriptPath).toMatch(new RegExp(`${params.sessionId}\\.jsonl$`));
|
||||
}
|
||||
|
||||
async function withStubbedStateDir<T>(
|
||||
name: string,
|
||||
run: (stateDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const stateDir = path.join(os.tmpdir(), name);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
try {
|
||||
return await run(stateDir);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
}
|
||||
|
||||
describe("sanitizeTextContent", () => {
|
||||
it("strips minimax tool call XML and downgraded markers", () => {
|
||||
const input =
|
||||
@@ -259,11 +209,11 @@ describe("sessions_list gating", () => {
|
||||
});
|
||||
|
||||
it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
|
||||
const tool = createMainSessionsListTool();
|
||||
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
|
||||
const result = await tool.execute("call1", {});
|
||||
expect(result.details).toMatchObject({
|
||||
count: 1,
|
||||
sessions: [{ key: MAIN_AGENT_SESSION_KEY }],
|
||||
sessions: [{ key: "agent:main:main" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -281,7 +231,10 @@ describe("sessions_list transcriptPath resolution", () => {
|
||||
});
|
||||
|
||||
it("resolves cross-agent transcript paths from agent defaults when gateway store path is relative", async () => {
|
||||
await withStubbedStateDir("openclaw-state-relative", async () => {
|
||||
const stateDir = path.join(os.tmpdir(), "openclaw-state-relative");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
try {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
path: "agents/main/sessions/sessions.json",
|
||||
sessions: [
|
||||
@@ -293,16 +246,27 @@ describe("sessions_list transcriptPath resolution", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executeMainSessionsList();
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: path.join("agents", "worker", "sessions"),
|
||||
sessionId: "sess-worker",
|
||||
});
|
||||
});
|
||||
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
|
||||
const result = await tool.execute("call1", {});
|
||||
|
||||
const details = result.details as
|
||||
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
|
||||
| undefined;
|
||||
const session = details?.sessions?.[0];
|
||||
expect(session).toMatchObject({ key: "agent:worker:main" });
|
||||
const transcriptPath = String(session?.transcriptPath ?? "");
|
||||
expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
|
||||
expect(transcriptPath).toMatch(/sess-worker\.jsonl$/);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves transcriptPath even when sessions.list does not return a store path", async () => {
|
||||
await withStubbedStateDir("openclaw-state-no-path", async () => {
|
||||
const stateDir = path.join(os.tmpdir(), "openclaw-state-no-path");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
try {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
sessions: [
|
||||
{
|
||||
@@ -313,16 +277,27 @@ describe("sessions_list transcriptPath resolution", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executeMainSessionsList();
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: path.join("agents", "worker", "sessions"),
|
||||
sessionId: "sess-worker-no-path",
|
||||
});
|
||||
});
|
||||
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
|
||||
const result = await tool.execute("call1", {});
|
||||
|
||||
const details = result.details as
|
||||
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
|
||||
| undefined;
|
||||
const session = details?.sessions?.[0];
|
||||
expect(session).toMatchObject({ key: "agent:worker:main" });
|
||||
const transcriptPath = String(session?.transcriptPath ?? "");
|
||||
expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
|
||||
expect(transcriptPath).toMatch(/sess-worker-no-path\.jsonl$/);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to agent defaults when gateway path is non-string", async () => {
|
||||
await withStubbedStateDir("openclaw-state-non-string-path", async () => {
|
||||
const stateDir = path.join(os.tmpdir(), "openclaw-state-non-string-path");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
try {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
path: { raw: "agents/main/sessions/sessions.json" },
|
||||
sessions: [
|
||||
@@ -334,16 +309,27 @@ describe("sessions_list transcriptPath resolution", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executeMainSessionsList();
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: path.join("agents", "worker", "sessions"),
|
||||
sessionId: "sess-worker-shape",
|
||||
});
|
||||
});
|
||||
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
|
||||
const result = await tool.execute("call1", {});
|
||||
|
||||
const details = result.details as
|
||||
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
|
||||
| undefined;
|
||||
const session = details?.sessions?.[0];
|
||||
expect(session).toMatchObject({ key: "agent:worker:main" });
|
||||
const transcriptPath = String(session?.transcriptPath ?? "");
|
||||
expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
|
||||
expect(transcriptPath).toMatch(/sess-worker-shape\.jsonl$/);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to agent defaults when gateway path is '(multiple)'", async () => {
|
||||
await withStubbedStateDir("openclaw-state-multiple", async (stateDir) => {
|
||||
const stateDir = path.join(os.tmpdir(), "openclaw-state-multiple");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
try {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
path: "(multiple)",
|
||||
sessions: [
|
||||
@@ -355,12 +341,22 @@ describe("sessions_list transcriptPath resolution", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executeMainSessionsList();
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: path.join(stateDir, "agents", "worker", "sessions"),
|
||||
sessionId: "sess-worker-multiple",
|
||||
});
|
||||
});
|
||||
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
|
||||
const result = await tool.execute("call1", {});
|
||||
|
||||
const details = result.details as
|
||||
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
|
||||
| undefined;
|
||||
const session = details?.sessions?.[0];
|
||||
expect(session).toMatchObject({ key: "agent:worker:main" });
|
||||
const transcriptPath = String(session?.transcriptPath ?? "");
|
||||
expect(path.normalize(transcriptPath)).toContain(
|
||||
path.join(stateDir, "agents", "worker", "sessions"),
|
||||
);
|
||||
expect(transcriptPath).toMatch(/sess-worker-multiple\.jsonl$/);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves absolute {agentId} template paths per session agent", async () => {
|
||||
@@ -377,12 +373,18 @@ describe("sessions_list transcriptPath resolution", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executeMainSessionsList();
|
||||
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
|
||||
const result = await tool.execute("call1", {});
|
||||
|
||||
const details = result.details as
|
||||
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
|
||||
| undefined;
|
||||
const session = details?.sessions?.[0];
|
||||
expect(session).toMatchObject({ key: "agent:worker:main" });
|
||||
const transcriptPath = String(session?.transcriptPath ?? "");
|
||||
const expectedSessionsDir = path.dirname(templateStorePath.replace("{agentId}", "worker"));
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: expectedSessionsDir,
|
||||
sessionId: "sess-worker-template",
|
||||
});
|
||||
expect(path.normalize(transcriptPath)).toContain(path.normalize(expectedSessionsDir));
|
||||
expect(transcriptPath).toMatch(/sess-worker-template\.jsonl$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,7 +394,10 @@ describe("sessions_send gating", () => {
|
||||
});
|
||||
|
||||
it("returns an error when neither sessionKey nor label is provided", async () => {
|
||||
const tool = createMainSessionsSendTool();
|
||||
const tool = createSessionsSendTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-missing-target", {
|
||||
message: "hi",
|
||||
@@ -408,7 +413,10 @@ describe("sessions_send gating", () => {
|
||||
|
||||
it("returns an error when label resolution fails", async () => {
|
||||
callGatewayMock.mockRejectedValueOnce(new Error("No session found with label: nope"));
|
||||
const tool = createMainSessionsSendTool();
|
||||
const tool = createSessionsSendTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-missing-label", {
|
||||
label: "nope",
|
||||
@@ -427,7 +435,10 @@ describe("sessions_send gating", () => {
|
||||
});
|
||||
|
||||
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
|
||||
const tool = createMainSessionsSendTool();
|
||||
const tool = createSessionsSendTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
sessionKey: "agent:other:main",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export { getApiKeyForModel, requireApiKey } from "../model-auth.js";
|
||||
export { runWithImageModelFallback } from "../model-fallback.js";
|
||||
export { ensureOpenClawModelsJson } from "../models-config.js";
|
||||
export { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
|
||||
export {
|
||||
createSandboxBridgeReadFile,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
type SandboxedBridgeMediaPathConfig,
|
||||
} from "../sandbox-media-paths.js";
|
||||
export type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
|
||||
export type { ToolFsPolicy } from "../tool-fs-policy.js";
|
||||
export { normalizeWorkspaceDir } from "../workspace-dir.js";
|
||||
export type { AnyAgentTool } from "./common.js";
|
||||
@@ -44,41 +44,18 @@ async function readOnboardingState(dir: string): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
async function expectBootstrapSeeded(dir: string) {
|
||||
await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).resolves.toBeUndefined();
|
||||
const state = await readOnboardingState(dir);
|
||||
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
}
|
||||
|
||||
async function expectCompletedWithoutBootstrap(dir: string) {
|
||||
await expect(fs.access(path.join(dir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
const state = await readOnboardingState(dir);
|
||||
expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
}
|
||||
|
||||
function expectSubagentAllowedBootstrapNames(files: WorkspaceBootstrapFile[]) {
|
||||
const names = files.map((file) => file.name);
|
||||
expect(names).toContain("AGENTS.md");
|
||||
expect(names).toContain("TOOLS.md");
|
||||
expect(names).toContain("SOUL.md");
|
||||
expect(names).toContain("IDENTITY.md");
|
||||
expect(names).toContain("USER.md");
|
||||
expect(names).not.toContain("HEARTBEAT.md");
|
||||
expect(names).not.toContain("BOOTSTRAP.md");
|
||||
expect(names).not.toContain("MEMORY.md");
|
||||
}
|
||||
|
||||
describe("ensureAgentWorkspace", () => {
|
||||
it("creates BOOTSTRAP.md and records a seeded marker for brand new workspaces", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
expect((await readOnboardingState(tempDir)).onboardingCompletedAt).toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
|
||||
).resolves.toBeUndefined();
|
||||
const state = await readOnboardingState(tempDir);
|
||||
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
expect(state.onboardingCompletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recovers partial initialization by creating BOOTSTRAP.md when marker is missing", async () => {
|
||||
@@ -87,7 +64,11 @@ describe("ensureAgentWorkspace", () => {
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectBootstrapSeeded(tempDir);
|
||||
await expect(
|
||||
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
|
||||
).resolves.toBeUndefined();
|
||||
const state = await readOnboardingState(tempDir);
|
||||
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("does not recreate BOOTSTRAP.md after completion, even when a core file is recreated", async () => {
|
||||
@@ -148,7 +129,12 @@ describe("ensureAgentWorkspace", () => {
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expectCompletedWithoutBootstrap(tempDir);
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
const state = await readOnboardingState(tempDir);
|
||||
expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,11 +233,27 @@ describe("filterBootstrapFilesForSession", () => {
|
||||
|
||||
it("filters to allowlist for subagent sessions", () => {
|
||||
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:subagent:task-1");
|
||||
expectSubagentAllowedBootstrapNames(result);
|
||||
const names = result.map((f) => f.name);
|
||||
expect(names).toContain("AGENTS.md");
|
||||
expect(names).toContain("TOOLS.md");
|
||||
expect(names).toContain("SOUL.md");
|
||||
expect(names).toContain("IDENTITY.md");
|
||||
expect(names).toContain("USER.md");
|
||||
expect(names).not.toContain("HEARTBEAT.md");
|
||||
expect(names).not.toContain("BOOTSTRAP.md");
|
||||
expect(names).not.toContain("MEMORY.md");
|
||||
});
|
||||
|
||||
it("filters to allowlist for cron sessions", () => {
|
||||
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:cron:daily-check");
|
||||
expectSubagentAllowedBootstrapNames(result);
|
||||
const names = result.map((f) => f.name);
|
||||
expect(names).toContain("AGENTS.md");
|
||||
expect(names).toContain("TOOLS.md");
|
||||
expect(names).toContain("SOUL.md");
|
||||
expect(names).toContain("IDENTITY.md");
|
||||
expect(names).toContain("USER.md");
|
||||
expect(names).not.toContain("HEARTBEAT.md");
|
||||
expect(names).not.toContain("BOOTSTRAP.md");
|
||||
expect(names).not.toContain("MEMORY.md");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,43 +124,6 @@ describe("abort detection", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function enqueueQueuedFollowupRun(params: {
|
||||
root: string;
|
||||
cfg: OpenClawConfig;
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
}) {
|
||||
const followupRun: FollowupRun = {
|
||||
prompt: "queued",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
agentId: "main",
|
||||
agentDir: path.join(params.root, "agent"),
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: "telegram",
|
||||
agentAccountId: "acct",
|
||||
sessionFile: path.join(params.root, "session.jsonl"),
|
||||
workspaceDir: path.join(params.root, "workspace"),
|
||||
config: params.cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
timeoutMs: 1000,
|
||||
blockReplyBreak: "text_end",
|
||||
},
|
||||
};
|
||||
enqueueFollowupRun(
|
||||
params.sessionKey,
|
||||
followupRun,
|
||||
{ mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
|
||||
"none",
|
||||
);
|
||||
}
|
||||
|
||||
function expectSessionLaneCleared(sessionKey: string) {
|
||||
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAbortMemoryForTest();
|
||||
acpManagerMocks.resolveSession.mockReset().mockReturnValue({ kind: "none" });
|
||||
@@ -375,7 +338,31 @@ describe("abort detection", () => {
|
||||
const { root, cfg } = await createAbortConfig({
|
||||
sessionIdsByKey: { [sessionKey]: sessionId },
|
||||
});
|
||||
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
|
||||
const followupRun: FollowupRun = {
|
||||
prompt: "queued",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
agentId: "main",
|
||||
agentDir: path.join(root, "agent"),
|
||||
sessionId,
|
||||
sessionKey,
|
||||
messageProvider: "telegram",
|
||||
agentAccountId: "acct",
|
||||
sessionFile: path.join(root, "session.jsonl"),
|
||||
workspaceDir: path.join(root, "workspace"),
|
||||
config: cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
timeoutMs: 1000,
|
||||
blockReplyBreak: "text_end",
|
||||
},
|
||||
};
|
||||
enqueueFollowupRun(
|
||||
sessionKey,
|
||||
followupRun,
|
||||
{ mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
|
||||
"none",
|
||||
);
|
||||
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
|
||||
|
||||
const result = await runStopCommand({
|
||||
@@ -387,7 +374,7 @@ describe("abort detection", () => {
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
|
||||
expectSessionLaneCleared(sessionKey);
|
||||
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
|
||||
});
|
||||
|
||||
it("plain-language stop on ACP-bound session triggers ACP cancel", async () => {
|
||||
@@ -424,7 +411,31 @@ describe("abort detection", () => {
|
||||
const { root, cfg } = await createAbortConfig({
|
||||
sessionIdsByKey: { [sessionKey]: sessionId },
|
||||
});
|
||||
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
|
||||
const followupRun: FollowupRun = {
|
||||
prompt: "queued",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
agentId: "main",
|
||||
agentDir: path.join(root, "agent"),
|
||||
sessionId,
|
||||
sessionKey,
|
||||
messageProvider: "telegram",
|
||||
agentAccountId: "acct",
|
||||
sessionFile: path.join(root, "session.jsonl"),
|
||||
workspaceDir: path.join(root, "workspace"),
|
||||
config: cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
timeoutMs: 1000,
|
||||
blockReplyBreak: "text_end",
|
||||
},
|
||||
};
|
||||
enqueueFollowupRun(
|
||||
sessionKey,
|
||||
followupRun,
|
||||
{ mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
|
||||
"none",
|
||||
);
|
||||
acpManagerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
sessionKey,
|
||||
@@ -442,7 +453,7 @@ describe("abort detection", () => {
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
|
||||
expectSessionLaneCleared(sessionKey);
|
||||
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
|
||||
});
|
||||
|
||||
it("persists abort cutoff metadata on /stop when command and target session match", async () => {
|
||||
@@ -535,7 +546,7 @@ describe("abort detection", () => {
|
||||
});
|
||||
|
||||
expect(result.stoppedSubagents).toBe(1);
|
||||
expectSessionLaneCleared(childKey);
|
||||
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${childKey}`);
|
||||
});
|
||||
|
||||
it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => {
|
||||
@@ -590,8 +601,8 @@ describe("abort detection", () => {
|
||||
|
||||
// Should stop both depth-1 and depth-2 agents (cascade)
|
||||
expect(result.stoppedSubagents).toBe(2);
|
||||
expectSessionLaneCleared(depth1Key);
|
||||
expectSessionLaneCleared(depth2Key);
|
||||
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth1Key}`);
|
||||
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`);
|
||||
});
|
||||
|
||||
it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => {
|
||||
@@ -649,7 +660,7 @@ describe("abort detection", () => {
|
||||
|
||||
// Should skip killing the ended depth-1 run itself, but still kill depth-2.
|
||||
expect(result.stoppedSubagents).toBe(1);
|
||||
expectSessionLaneCleared(depth2Key);
|
||||
expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`);
|
||||
expect(subagentRegistryMocks.markSubagentRunTerminated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runId: "run-2", childSessionKey: depth2Key }),
|
||||
);
|
||||
|
||||
@@ -3,39 +3,17 @@ import { prefixSystemMessage } from "../../infra/system-message.js";
|
||||
import { createAcpReplyProjector } from "./acp-projector.js";
|
||||
import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js";
|
||||
|
||||
type Delivery = { kind: string; text?: string };
|
||||
|
||||
function createProjectorHarness(cfgOverrides?: Parameters<typeof createCfg>[0]) {
|
||||
const deliveries: Delivery[] = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg(cfgOverrides),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return { deliveries, projector };
|
||||
}
|
||||
|
||||
function blockDeliveries(deliveries: Delivery[]) {
|
||||
return deliveries.filter((entry) => entry.kind === "block");
|
||||
}
|
||||
|
||||
function combinedBlockText(deliveries: Delivery[]) {
|
||||
return blockDeliveries(deliveries)
|
||||
.map((entry) => entry.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function expectToolCallSummary(delivery: Delivery | undefined) {
|
||||
expect(delivery?.kind).toBe("tool");
|
||||
expect(delivery?.text).toContain("Tool Call");
|
||||
}
|
||||
|
||||
describe("createAcpReplyProjector", () => {
|
||||
it("coalesces text deltas into bounded block chunks", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness();
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg(),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await projector.onEvent({
|
||||
type: "text_delta",
|
||||
@@ -51,14 +29,22 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
it("does not suppress identical short text across terminal turn boundaries", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 64,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 64,
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,7 +53,7 @@ describe("createAcpReplyProjector", () => {
|
||||
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
|
||||
await projector.onEvent({ type: "done", stopReason: "end_turn" });
|
||||
|
||||
expect(blockDeliveries(deliveries)).toEqual([
|
||||
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
|
||||
{ kind: "block", text: "A" },
|
||||
{ kind: "block", text: "A" },
|
||||
]);
|
||||
@@ -76,14 +62,22 @@ describe("createAcpReplyProjector", () => {
|
||||
it("flushes staggered live text deltas after idle gaps", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
coalesceIdleMs: 50,
|
||||
maxChunkChars: 64,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
coalesceIdleMs: 50,
|
||||
maxChunkChars: 64,
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,7 +93,7 @@ describe("createAcpReplyProjector", () => {
|
||||
await vi.advanceTimersByTimeAsync(760);
|
||||
await projector.flush(false);
|
||||
|
||||
expect(blockDeliveries(deliveries)).toEqual([
|
||||
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
|
||||
{ kind: "block", text: "A" },
|
||||
{ kind: "block", text: "B" },
|
||||
{ kind: "block", text: "C" },
|
||||
@@ -110,14 +104,22 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
it("splits oversized live text by maxChunkChars", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 50,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 50,
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -125,7 +127,7 @@ describe("createAcpReplyProjector", () => {
|
||||
await projector.onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
|
||||
await projector.flush(true);
|
||||
|
||||
expect(blockDeliveries(deliveries)).toEqual([
|
||||
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
|
||||
{ kind: "block", text: "a".repeat(50) },
|
||||
{ kind: "block", text: "b".repeat(50) },
|
||||
{ kind: "block", text: "c".repeat(20) },
|
||||
@@ -135,14 +137,22 @@ describe("createAcpReplyProjector", () => {
|
||||
it("does not flush short live fragments mid-phrase on idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
coalesceIdleMs: 100,
|
||||
maxChunkChars: 256,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
coalesceIdleMs: 100,
|
||||
maxChunkChars: 256,
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -174,18 +184,26 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
it("supports deliveryMode=final_only by buffering all projected output until done", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 512,
|
||||
deliveryMode: "final_only",
|
||||
tagVisibility: {
|
||||
available_commands_update: true,
|
||||
tool_call: true,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 512,
|
||||
deliveryMode: "final_only",
|
||||
tagVisibility: {
|
||||
available_commands_update: true,
|
||||
tool_call: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -220,23 +238,32 @@ describe("createAcpReplyProjector", () => {
|
||||
kind: "tool",
|
||||
text: prefixSystemMessage("available commands updated (7)"),
|
||||
});
|
||||
expectToolCallSummary(deliveries[1]);
|
||||
expect(deliveries[1]?.kind).toBe("tool");
|
||||
expect(deliveries[1]?.text).toContain("Tool Call");
|
||||
expect(deliveries[2]).toEqual({ kind: "block", text: "What now?" });
|
||||
});
|
||||
|
||||
it("flushes buffered status/tool output on error in deliveryMode=final_only", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 512,
|
||||
deliveryMode: "final_only",
|
||||
tagVisibility: {
|
||||
available_commands_update: true,
|
||||
tool_call: true,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 512,
|
||||
deliveryMode: "final_only",
|
||||
tagVisibility: {
|
||||
available_commands_update: true,
|
||||
tool_call: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -261,11 +288,20 @@ describe("createAcpReplyProjector", () => {
|
||||
kind: "tool",
|
||||
text: prefixSystemMessage("available commands updated (7)"),
|
||||
});
|
||||
expectToolCallSummary(deliveries[1]);
|
||||
expect(deliveries[1]?.kind).toBe("tool");
|
||||
expect(deliveries[1]?.text).toContain("Tool Call");
|
||||
});
|
||||
|
||||
it("suppresses usage_update by default and allows deduped usage when tag-visible", async () => {
|
||||
const { deliveries: hidden, projector: hiddenProjector } = createProjectorHarness();
|
||||
const hidden: Array<{ kind: string; text?: string }> = [];
|
||||
const hiddenProjector = createAcpReplyProjector({
|
||||
cfg: createCfg(),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
hidden.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await hiddenProjector.onEvent({
|
||||
type: "status",
|
||||
text: "usage updated: 10/100",
|
||||
@@ -275,17 +311,25 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
expect(hidden).toEqual([]);
|
||||
|
||||
const { deliveries: shown, projector: shownProjector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 64,
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
usage_update: true,
|
||||
const shown: Array<{ kind: string; text?: string }> = [];
|
||||
const shownProjector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 64,
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
usage_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
shown.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -318,7 +362,15 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
it("hides available_commands_update by default", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness();
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg(),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await projector.onEvent({
|
||||
type: "status",
|
||||
text: "available commands updated (7)",
|
||||
@@ -329,16 +381,24 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
tool_call: true,
|
||||
tool_call_update: true,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
tool_call: true,
|
||||
tool_call_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -376,22 +436,32 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
expect(deliveries.length).toBe(2);
|
||||
expectToolCallSummary(deliveries[0]);
|
||||
expectToolCallSummary(deliveries[1]);
|
||||
expect(deliveries[0]?.kind).toBe("tool");
|
||||
expect(deliveries[0]?.text).toContain("Tool Call");
|
||||
expect(deliveries[1]?.kind).toBe("tool");
|
||||
expect(deliveries[1]?.text).toContain("Tool Call");
|
||||
});
|
||||
|
||||
it("keeps terminal tool updates even when rendered summaries are truncated", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
maxSessionUpdateChars: 48,
|
||||
tagVisibility: {
|
||||
tool_call: true,
|
||||
tool_call_update: true,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
maxSessionUpdateChars: 48,
|
||||
tagVisibility: {
|
||||
tool_call: true,
|
||||
tool_call_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -415,21 +485,29 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
expect(deliveries.length).toBe(2);
|
||||
expectToolCallSummary(deliveries[0]);
|
||||
expectToolCallSummary(deliveries[1]);
|
||||
expect(deliveries[0]?.kind).toBe("tool");
|
||||
expect(deliveries[1]?.kind).toBe("tool");
|
||||
});
|
||||
|
||||
it("renders fallback tool labels without leaking call ids as primary label", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
tool_call: true,
|
||||
tool_call_update: true,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
tool_call: true,
|
||||
tool_call_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -441,25 +519,33 @@ describe("createAcpReplyProjector", () => {
|
||||
text: "call_ABC123 (in_progress)",
|
||||
});
|
||||
|
||||
expectToolCallSummary(deliveries[0]);
|
||||
expect(deliveries[0]?.text).toContain("Tool Call");
|
||||
expect(deliveries[0]?.text).not.toContain("call_ABC123 (");
|
||||
});
|
||||
|
||||
it("allows repeated status/tool summaries when repeatSuppression is disabled", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
repeatSuppression: false,
|
||||
tagVisibility: {
|
||||
available_commands_update: true,
|
||||
tool_call: true,
|
||||
tool_call_update: true,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
repeatSuppression: false,
|
||||
tagVisibility: {
|
||||
available_commands_update: true,
|
||||
tool_call: true,
|
||||
tool_call_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -503,23 +589,31 @@ describe("createAcpReplyProjector", () => {
|
||||
kind: "tool",
|
||||
text: prefixSystemMessage("available commands updated"),
|
||||
});
|
||||
expectToolCallSummary(deliveries[2]);
|
||||
expectToolCallSummary(deliveries[3]);
|
||||
expect(deliveries[2]?.text).toContain("Tool Call");
|
||||
expect(deliveries[3]?.text).toContain("Tool Call");
|
||||
expect(deliveries[4]).toEqual({ kind: "block", text: "hello" });
|
||||
});
|
||||
|
||||
it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
available_commands_update: true,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
available_commands_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -546,15 +640,23 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
it("truncates oversized turns once and emits one truncation notice", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
maxOutputChars: 5,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
maxOutputChars: 5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -579,18 +681,26 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
it("supports tagVisibility overrides for tool updates", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
tool_call: true,
|
||||
tool_call_update: false,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
tool_call: true,
|
||||
tool_call_update: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -612,18 +722,26 @@ describe("createAcpReplyProjector", () => {
|
||||
});
|
||||
|
||||
expect(deliveries.length).toBe(1);
|
||||
expectToolCallSummary(deliveries[0]);
|
||||
expect(deliveries[0]?.text).toContain("Tool Call");
|
||||
});
|
||||
|
||||
it("inserts a space boundary before visible text after hidden tool updates by default", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -639,22 +757,34 @@ describe("createAcpReplyProjector", () => {
|
||||
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
|
||||
await projector.flush(true);
|
||||
|
||||
expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
|
||||
const combinedText = deliveries
|
||||
.filter((entry) => entry.kind === "block")
|
||||
.map((entry) => entry.text ?? "")
|
||||
.join("");
|
||||
expect(combinedText).toBe("fallback. I don't");
|
||||
});
|
||||
|
||||
it("preserves hidden boundary across nonterminal hidden tool updates", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
tool_call: false,
|
||||
tool_call_update: false,
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
tagVisibility: {
|
||||
tool_call: false,
|
||||
tool_call_update: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -678,19 +808,31 @@ describe("createAcpReplyProjector", () => {
|
||||
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
|
||||
await projector.flush(true);
|
||||
|
||||
expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
|
||||
const combinedText = deliveries
|
||||
.filter((entry) => entry.kind === "block")
|
||||
.map((entry) => entry.text ?? "")
|
||||
.join("");
|
||||
expect(combinedText).toBe("fallback. I don't");
|
||||
});
|
||||
|
||||
it("supports hiddenBoundarySeparator=space", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
hiddenBoundarySeparator: "space",
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
hiddenBoundarySeparator: "space",
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -706,19 +848,31 @@ describe("createAcpReplyProjector", () => {
|
||||
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
|
||||
await projector.flush(true);
|
||||
|
||||
expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
|
||||
const combinedText = deliveries
|
||||
.filter((entry) => entry.kind === "block")
|
||||
.map((entry) => entry.text ?? "")
|
||||
.join("");
|
||||
expect(combinedText).toBe("fallback. I don't");
|
||||
});
|
||||
|
||||
it("supports hiddenBoundarySeparator=none", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
hiddenBoundarySeparator: "none",
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
hiddenBoundarySeparator: "none",
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -734,18 +888,30 @@ describe("createAcpReplyProjector", () => {
|
||||
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
|
||||
await projector.flush(true);
|
||||
|
||||
expect(combinedBlockText(deliveries)).toBe("fallback.I don't");
|
||||
const combinedText = deliveries
|
||||
.filter((entry) => entry.kind === "block")
|
||||
.map((entry) => entry.text ?? "")
|
||||
.join("");
|
||||
expect(combinedText).toBe("fallback.I don't");
|
||||
});
|
||||
|
||||
it("does not duplicate newlines when previous visible text already ends with newline", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -765,18 +931,30 @@ describe("createAcpReplyProjector", () => {
|
||||
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
|
||||
await projector.flush(true);
|
||||
|
||||
expect(combinedBlockText(deliveries)).toBe("fallback.\nI don't");
|
||||
const combinedText = deliveries
|
||||
.filter((entry) => entry.kind === "block")
|
||||
.map((entry) => entry.text ?? "")
|
||||
.join("");
|
||||
expect(combinedText).toBe("fallback.\nI don't");
|
||||
});
|
||||
|
||||
it("does not insert boundary separator for hidden non-tool status updates", async () => {
|
||||
const { deliveries, projector } = createProjectorHarness({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||
const projector = createAcpReplyProjector({
|
||||
cfg: createCfg({
|
||||
acp: {
|
||||
enabled: true,
|
||||
stream: {
|
||||
coalesceIdleMs: 0,
|
||||
maxChunkChars: 256,
|
||||
deliveryMode: "live",
|
||||
},
|
||||
},
|
||||
}),
|
||||
shouldSendToolSummaries: true,
|
||||
deliver: async (kind, payload) => {
|
||||
deliveries.push({ kind, text: payload.text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -789,6 +967,10 @@ describe("createAcpReplyProjector", () => {
|
||||
await projector.onEvent({ type: "text_delta", text: "B", tag: "agent_message_chunk" });
|
||||
await projector.flush(true);
|
||||
|
||||
expect(combinedBlockText(deliveries)).toBe("AB");
|
||||
const combinedText = deliveries
|
||||
.filter((entry) => entry.kind === "block")
|
||||
.map((entry) => entry.text ?? "")
|
||||
.join("");
|
||||
expect(combinedText).toBe("AB");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,22 +52,6 @@ const hoisted = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
function createAcpCommandSessionBindingService() {
|
||||
const forward =
|
||||
<A extends unknown[], T>(fn: (...args: A) => T) =>
|
||||
(...args: A) =>
|
||||
fn(...args);
|
||||
return {
|
||||
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
|
||||
getCapabilities: forward((params: unknown) => hoisted.sessionBindingCapabilitiesMock(params)),
|
||||
listBySession: (targetSessionKey: string) =>
|
||||
hoisted.sessionBindingListBySessionMock(targetSessionKey),
|
||||
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
|
||||
touch: vi.fn(),
|
||||
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (args: unknown) => hoisted.callGatewayMock(args),
|
||||
}));
|
||||
@@ -95,11 +79,18 @@ vi.mock("../../config/sessions.js", async (importOriginal) => {
|
||||
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
|
||||
const patched = { ...actual } as typeof actual & {
|
||||
getSessionBindingService: () => ReturnType<typeof createAcpCommandSessionBindingService>;
|
||||
return {
|
||||
...actual,
|
||||
getSessionBindingService: () => ({
|
||||
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
|
||||
getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
|
||||
listBySession: (targetSessionKey: string) =>
|
||||
hoisted.sessionBindingListBySessionMock(targetSessionKey),
|
||||
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
|
||||
touch: vi.fn(),
|
||||
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
|
||||
}),
|
||||
};
|
||||
patched.getSessionBindingService = () => createAcpCommandSessionBindingService();
|
||||
return patched;
|
||||
});
|
||||
|
||||
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
|
||||
@@ -181,128 +172,6 @@ function createDiscordParams(commandBody: string, cfg: OpenClawConfig = baseCfg)
|
||||
return params;
|
||||
}
|
||||
|
||||
const defaultAcpSessionKey = "agent:codex:acp:s1";
|
||||
const defaultThreadId = "thread-1";
|
||||
|
||||
type AcpSessionIdentity = {
|
||||
state: "resolved";
|
||||
source: "status";
|
||||
acpxSessionId: string;
|
||||
agentSessionId: string;
|
||||
lastUpdatedAt: number;
|
||||
};
|
||||
|
||||
function createThreadConversation(conversationId: string = defaultThreadId) {
|
||||
return {
|
||||
channel: "discord" as const,
|
||||
accountId: "default",
|
||||
conversationId,
|
||||
parentConversationId: "parent-1",
|
||||
};
|
||||
}
|
||||
|
||||
function createBoundThreadSession(sessionKey: string = defaultAcpSessionKey) {
|
||||
return createSessionBinding({
|
||||
targetSessionKey: sessionKey,
|
||||
conversation: createThreadConversation(),
|
||||
});
|
||||
}
|
||||
|
||||
function createAcpSessionEntry(options?: {
|
||||
sessionKey?: string;
|
||||
state?: "idle" | "running";
|
||||
identity?: AcpSessionIdentity;
|
||||
}) {
|
||||
const sessionKey = options?.sessionKey ?? defaultAcpSessionKey;
|
||||
return {
|
||||
sessionKey,
|
||||
storeSessionKey: sessionKey,
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
...(options?.identity ? { identity: options.identity } : {}),
|
||||
mode: "persistent",
|
||||
state: options?.state ?? "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionBindingCapabilities() {
|
||||
return {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"] as const,
|
||||
};
|
||||
}
|
||||
|
||||
type AcpBindInput = {
|
||||
targetSessionKey: string;
|
||||
conversation: { accountId: string; conversationId: string };
|
||||
placement: "current" | "child";
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
|
||||
const nextConversationId =
|
||||
input.placement === "child" ? "thread-created" : input.conversation.conversationId;
|
||||
const boundBy = typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1";
|
||||
return createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
metadata: { boundBy, webhookId: "wh-1" },
|
||||
});
|
||||
}
|
||||
|
||||
function expectBoundIntroTextToExclude(match: string): void {
|
||||
const calls = hoisted.sessionBindingBindMock.mock.calls as Array<
|
||||
[{ metadata?: { introText?: unknown } }]
|
||||
>;
|
||||
const introText = calls
|
||||
.map((call) => call[0]?.metadata?.introText)
|
||||
.find((value): value is string => typeof value === "string");
|
||||
expect((introText ?? "").includes(match)).toBe(false);
|
||||
}
|
||||
|
||||
function mockBoundThreadSession(options?: {
|
||||
sessionKey?: string;
|
||||
state?: "idle" | "running";
|
||||
identity?: AcpSessionIdentity;
|
||||
}) {
|
||||
const sessionKey = options?.sessionKey ?? defaultAcpSessionKey;
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createBoundThreadSession(sessionKey),
|
||||
);
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue(
|
||||
createAcpSessionEntry({
|
||||
sessionKey,
|
||||
state: options?.state,
|
||||
identity: options?.identity,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = createDiscordParams(commandBody, cfg);
|
||||
params.ctx.MessageThreadId = defaultThreadId;
|
||||
return params;
|
||||
}
|
||||
|
||||
async function runDiscordAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createDiscordParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
async function runThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createThreadParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
describe("/acp command", () => {
|
||||
beforeEach(() => {
|
||||
acpManagerTesting.resetAcpSessionManagerForTests();
|
||||
@@ -326,12 +195,37 @@ describe("/acp command", () => {
|
||||
storePath: "/tmp/sessions-acp.json",
|
||||
});
|
||||
hoisted.loadSessionStoreMock.mockReset().mockReturnValue({});
|
||||
hoisted.sessionBindingCapabilitiesMock
|
||||
.mockReset()
|
||||
.mockReturnValue(createSessionBindingCapabilities());
|
||||
hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
});
|
||||
hoisted.sessionBindingBindMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (input: AcpBindInput) => createAcpThreadBinding(input));
|
||||
.mockImplementation(
|
||||
async (input: {
|
||||
targetSessionKey: string;
|
||||
conversation: { accountId: string; conversationId: string };
|
||||
placement: "current" | "child";
|
||||
metadata?: Record<string, unknown>;
|
||||
}) =>
|
||||
createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId:
|
||||
input.placement === "child" ? "thread-created" : input.conversation.conversationId,
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
metadata: {
|
||||
boundBy:
|
||||
typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
|
||||
webhookId: "wh-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
|
||||
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
|
||||
@@ -381,12 +275,14 @@ describe("/acp command", () => {
|
||||
});
|
||||
|
||||
it("returns null when the message is not /acp", async () => {
|
||||
const result = await runDiscordAcpCommand("/status");
|
||||
const params = createDiscordParams("/status");
|
||||
const result = await handleAcpCommand(params, true);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("shows help by default", async () => {
|
||||
const result = await runDiscordAcpCommand("/acp");
|
||||
const params = createDiscordParams("/acp");
|
||||
const result = await handleAcpCommand(params, true);
|
||||
expect(result?.reply?.text).toContain("ACP commands:");
|
||||
expect(result?.reply?.text).toContain("/acp spawn");
|
||||
});
|
||||
@@ -400,7 +296,8 @@ describe("/acp command", () => {
|
||||
backendSessionId: "acpx-1",
|
||||
});
|
||||
|
||||
const result = await runDiscordAcpCommand("/acp spawn codex --cwd /home/bob/clawd");
|
||||
const params = createDiscordParams("/acp spawn codex --cwd /home/bob/clawd");
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
|
||||
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
|
||||
@@ -421,7 +318,15 @@ describe("/acp command", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expectBoundIntroTextToExclude("session ids: pending (available after the first reply)");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
introText: expect.not.stringContaining(
|
||||
"session ids: pending (available after the first reply)",
|
||||
),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(hoisted.callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "sessions.patch",
|
||||
@@ -447,7 +352,8 @@ describe("/acp command", () => {
|
||||
});
|
||||
|
||||
it("requires explicit ACP target when acp.defaultAgent is not configured", async () => {
|
||||
const result = await runDiscordAcpCommand("/acp spawn");
|
||||
const params = createDiscordParams("/acp spawn");
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("ACP target agent is required");
|
||||
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
|
||||
@@ -466,7 +372,8 @@ describe("/acp command", () => {
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runDiscordAcpCommand("/acp spawn codex", cfg);
|
||||
const params = createDiscordParams("/acp spawn codex", cfg);
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
|
||||
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
|
||||
@@ -486,14 +393,38 @@ describe("/acp command", () => {
|
||||
});
|
||||
|
||||
it("cancels the ACP session bound to the current thread", async () => {
|
||||
mockBoundThreadSession({ state: "running" });
|
||||
const result = await runThreadAcpCommand("/acp cancel", baseCfg);
|
||||
expect(result?.reply?.text).toContain(
|
||||
`Cancel requested for ACP session ${defaultAcpSessionKey}`,
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBinding({
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
storeSessionKey: "agent:codex:acp:s1",
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
state: "running",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const params = createDiscordParams("/acp cancel", baseCfg);
|
||||
params.ctx.MessageThreadId = "thread-1";
|
||||
|
||||
const result = await handleAcpCommand(params, true);
|
||||
expect(result?.reply?.text).toContain("Cancel requested for ACP session agent:codex:acp:s1");
|
||||
expect(hoisted.cancelMock).toHaveBeenCalledWith({
|
||||
handle: expect.objectContaining({
|
||||
sessionKey: defaultAcpSessionKey,
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
backend: "acpx",
|
||||
}),
|
||||
reason: "manual-cancel",
|
||||
@@ -503,19 +434,29 @@ describe("/acp command", () => {
|
||||
it("sends steer instructions via ACP runtime", async () => {
|
||||
hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => {
|
||||
if (request.method === "sessions.resolve") {
|
||||
return { key: defaultAcpSessionKey };
|
||||
return { key: "agent:codex:acp:s1" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue(createAcpSessionEntry());
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
storeSessionKey: "agent:codex:acp:s1",
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
hoisted.runTurnMock.mockImplementation(async function* () {
|
||||
yield { type: "text_delta", text: "Applied steering." };
|
||||
yield { type: "done" };
|
||||
});
|
||||
|
||||
const result = await runDiscordAcpCommand(
|
||||
`/acp steer --session ${defaultAcpSessionKey} tighten logging`,
|
||||
);
|
||||
const params = createDiscordParams("/acp steer --session agent:codex:acp:s1 tighten logging");
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(hoisted.runTurnMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -534,23 +475,57 @@ describe("/acp command", () => {
|
||||
dispatch: { enabled: false },
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const result = await runDiscordAcpCommand("/acp steer tighten logging", cfg);
|
||||
const params = createDiscordParams("/acp steer tighten logging", cfg);
|
||||
const result = await handleAcpCommand(params, true);
|
||||
expect(result?.reply?.text).toContain("ACP dispatch is disabled by policy");
|
||||
expect(hoisted.runTurnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes an ACP session, unbinds thread targets, and clears metadata", async () => {
|
||||
mockBoundThreadSession();
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBinding({
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
storeSessionKey: "agent:codex:acp:s1",
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
hoisted.sessionBindingUnbindMock.mockResolvedValue([
|
||||
createBoundThreadSession() as SessionBindingRecord,
|
||||
createSessionBinding({
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}) as SessionBindingRecord,
|
||||
]);
|
||||
|
||||
const result = await runThreadAcpCommand("/acp close", baseCfg);
|
||||
const params = createDiscordParams("/acp close", baseCfg);
|
||||
params.ctx.MessageThreadId = "thread-1";
|
||||
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetSessionKey: defaultAcpSessionKey,
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
reason: "manual",
|
||||
}),
|
||||
);
|
||||
@@ -560,10 +535,22 @@ describe("/acp command", () => {
|
||||
|
||||
it("lists ACP sessions from the session store", async () => {
|
||||
hoisted.sessionBindingListBySessionMock.mockImplementation((key: string) =>
|
||||
key === defaultAcpSessionKey ? [createBoundThreadSession(key) as SessionBindingRecord] : [],
|
||||
key === "agent:codex:acp:s1"
|
||||
? [
|
||||
createSessionBinding({
|
||||
targetSessionKey: key,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}) as SessionBindingRecord,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
hoisted.loadSessionStoreMock.mockReturnValue({
|
||||
[defaultAcpSessionKey]: {
|
||||
"agent:codex:acp:s1": {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: Date.now(),
|
||||
label: "codex-main",
|
||||
@@ -582,27 +569,52 @@ describe("/acp command", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runDiscordAcpCommand("/acp sessions", baseCfg);
|
||||
const params = createDiscordParams("/acp sessions", baseCfg);
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("ACP sessions:");
|
||||
expect(result?.reply?.text).toContain("codex-main");
|
||||
expect(result?.reply?.text).toContain(`thread:${defaultThreadId}`);
|
||||
expect(result?.reply?.text).toContain("thread:thread-1");
|
||||
});
|
||||
|
||||
it("shows ACP status for the thread-bound ACP session", async () => {
|
||||
mockBoundThreadSession({
|
||||
identity: {
|
||||
state: "resolved",
|
||||
source: "status",
|
||||
acpxSessionId: "acpx-sid-1",
|
||||
agentSessionId: "codex-sid-1",
|
||||
lastUpdatedAt: Date.now(),
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBinding({
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
storeSessionKey: "agent:codex:acp:s1",
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
identity: {
|
||||
state: "resolved",
|
||||
source: "status",
|
||||
acpxSessionId: "acpx-sid-1",
|
||||
agentSessionId: "codex-sid-1",
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const result = await runThreadAcpCommand("/acp status", baseCfg);
|
||||
const params = createDiscordParams("/acp status", baseCfg);
|
||||
params.ctx.MessageThreadId = "thread-1";
|
||||
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("ACP status:");
|
||||
expect(result?.reply?.text).toContain(`session: ${defaultAcpSessionKey}`);
|
||||
expect(result?.reply?.text).toContain("session: agent:codex:acp:s1");
|
||||
expect(result?.reply?.text).toContain("agent session id: codex-sid-1");
|
||||
expect(result?.reply?.text).toContain("acpx session id: acpx-sid-1");
|
||||
expect(result?.reply?.text).toContain("capabilities:");
|
||||
@@ -610,8 +622,33 @@ describe("/acp command", () => {
|
||||
});
|
||||
|
||||
it("updates ACP runtime mode via /acp set-mode", async () => {
|
||||
mockBoundThreadSession();
|
||||
const result = await runThreadAcpCommand("/acp set-mode plan", baseCfg);
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBinding({
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
storeSessionKey: "agent:codex:acp:s1",
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const params = createDiscordParams("/acp set-mode plan", baseCfg);
|
||||
params.ctx.MessageThreadId = "thread-1";
|
||||
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(hoisted.setModeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -622,9 +659,33 @@ describe("/acp command", () => {
|
||||
});
|
||||
|
||||
it("updates ACP config options and keeps cwd local when using /acp set", async () => {
|
||||
mockBoundThreadSession();
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBinding({
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
storeSessionKey: "agent:codex:acp:s1",
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const setModel = await runThreadAcpCommand("/acp set model gpt-5.3-codex", baseCfg);
|
||||
const setModelParams = createDiscordParams("/acp set model gpt-5.3-codex", baseCfg);
|
||||
setModelParams.ctx.MessageThreadId = "thread-1";
|
||||
const setModel = await handleAcpCommand(setModelParams, true);
|
||||
expect(hoisted.setConfigOptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "model",
|
||||
@@ -634,24 +695,74 @@ describe("/acp command", () => {
|
||||
expect(setModel?.reply?.text).toContain("Updated ACP config option");
|
||||
|
||||
hoisted.setConfigOptionMock.mockClear();
|
||||
const setCwd = await runThreadAcpCommand("/acp set cwd /tmp/worktree", baseCfg);
|
||||
const setCwdParams = createDiscordParams("/acp set cwd /tmp/worktree", baseCfg);
|
||||
setCwdParams.ctx.MessageThreadId = "thread-1";
|
||||
const setCwd = await handleAcpCommand(setCwdParams, true);
|
||||
expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled();
|
||||
expect(setCwd?.reply?.text).toContain("Updated ACP cwd");
|
||||
});
|
||||
|
||||
it("rejects non-absolute cwd values via ACP runtime option validation", async () => {
|
||||
mockBoundThreadSession();
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBinding({
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
storeSessionKey: "agent:codex:acp:s1",
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runThreadAcpCommand("/acp cwd relative/path", baseCfg);
|
||||
const params = createDiscordParams("/acp cwd relative/path", baseCfg);
|
||||
params.ctx.MessageThreadId = "thread-1";
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)");
|
||||
expect(result?.reply?.text).toContain("absolute path");
|
||||
});
|
||||
|
||||
it("rejects invalid timeout values before backend config writes", async () => {
|
||||
mockBoundThreadSession();
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createSessionBinding({
|
||||
targetSessionKey: "agent:codex:acp:s1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:s1",
|
||||
storeSessionKey: "agent:codex:acp:s1",
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runThreadAcpCommand("/acp timeout 10s", baseCfg);
|
||||
const params = createDiscordParams("/acp timeout 10s", baseCfg);
|
||||
params.ctx.MessageThreadId = "thread-1";
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)");
|
||||
expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled();
|
||||
@@ -666,7 +777,8 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const result = await runDiscordAcpCommand("/acp doctor", baseCfg);
|
||||
const params = createDiscordParams("/acp doctor", baseCfg);
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("ACP doctor:");
|
||||
expect(result?.reply?.text).toContain("healthy: no");
|
||||
@@ -674,7 +786,8 @@ describe("/acp command", () => {
|
||||
});
|
||||
|
||||
it("shows deterministic install instructions via /acp install", async () => {
|
||||
const result = await runDiscordAcpCommand("/acp install", baseCfg);
|
||||
const params = createDiscordParams("/acp install", baseCfg);
|
||||
const result = await handleAcpCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("ACP install:");
|
||||
expect(result?.reply?.text).toContain("run:");
|
||||
|
||||
@@ -30,28 +30,6 @@ const hoisted = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
function buildFocusSessionBindingService() {
|
||||
const service = {
|
||||
touch: vi.fn(),
|
||||
listBySession(targetSessionKey: string) {
|
||||
return hoisted.sessionBindingListBySessionMock(targetSessionKey);
|
||||
},
|
||||
resolveByConversation(ref: unknown) {
|
||||
return hoisted.sessionBindingResolveByConversationMock(ref);
|
||||
},
|
||||
getCapabilities(params: unknown) {
|
||||
return hoisted.sessionBindingCapabilitiesMock(params);
|
||||
},
|
||||
bind(input: unknown) {
|
||||
return hoisted.sessionBindingBindMock(input);
|
||||
},
|
||||
unbind(input: unknown) {
|
||||
return hoisted.sessionBindingUnbindMock(input);
|
||||
},
|
||||
};
|
||||
return service;
|
||||
}
|
||||
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: hoisted.callGatewayMock,
|
||||
}));
|
||||
@@ -78,7 +56,15 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal
|
||||
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getSessionBindingService: () => buildFocusSessionBindingService(),
|
||||
getSessionBindingService: () => ({
|
||||
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
|
||||
getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
|
||||
listBySession: (targetSessionKey: string) =>
|
||||
hoisted.sessionBindingListBySessionMock(targetSessionKey),
|
||||
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
|
||||
touch: vi.fn(),
|
||||
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -231,33 +217,13 @@ function createSessionBindingRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionBindingCapabilities() {
|
||||
return {
|
||||
async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) {
|
||||
hoisted.sessionBindingCapabilitiesMock.mockReturnValue({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"] as const,
|
||||
};
|
||||
}
|
||||
|
||||
async function runUnfocusAndExpectManualUnbind(initialBindings: FakeBinding[]) {
|
||||
const fake = createFakeThreadBindingManager(initialBindings);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
const params = createDiscordCommandParams("/unfocus");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("Thread unfocused");
|
||||
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "thread-1",
|
||||
reason: "manual",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) {
|
||||
hoisted.sessionBindingCapabilitiesMock.mockReturnValue(createSessionBindingCapabilities());
|
||||
placements: ["current", "child"],
|
||||
});
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(options?.existingBinding ?? null);
|
||||
hoisted.sessionBindingBindMock.mockImplementation(
|
||||
async (input: {
|
||||
@@ -290,12 +256,6 @@ async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindin
|
||||
return { result };
|
||||
}
|
||||
|
||||
async function runAgentsCommandAndText(): Promise<string> {
|
||||
const params = createDiscordCommandParams("/agents");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
return result?.reply?.text ?? "";
|
||||
}
|
||||
|
||||
describe("/focus, /unfocus, /agents", () => {
|
||||
beforeEach(() => {
|
||||
resetSubagentRegistryForTests();
|
||||
@@ -303,9 +263,12 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
hoisted.getThreadBindingManagerMock.mockClear().mockReturnValue(null);
|
||||
hoisted.resolveThreadBindingThreadNameMock.mockClear().mockReturnValue("🤖 codex");
|
||||
hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null);
|
||||
hoisted.sessionBindingCapabilitiesMock
|
||||
.mockReset()
|
||||
.mockReturnValue(createSessionBindingCapabilities());
|
||||
hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
});
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
|
||||
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
|
||||
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
|
||||
@@ -377,11 +340,23 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
});
|
||||
|
||||
it("/unfocus removes an active thread binding for the binding owner", async () => {
|
||||
await runUnfocusAndExpectManualUnbind([createStoredBinding()]);
|
||||
const fake = createFakeThreadBindingManager([createStoredBinding()]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
const params = createDiscordCommandParams("/unfocus");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("Thread unfocused");
|
||||
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "thread-1",
|
||||
reason: "manual",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("/unfocus also unbinds ACP-focused thread bindings", async () => {
|
||||
await runUnfocusAndExpectManualUnbind([
|
||||
const fake = createFakeThreadBindingManager([
|
||||
createStoredBinding({
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:codex:acp:session-1",
|
||||
@@ -389,6 +364,18 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
label: "codex-session",
|
||||
}),
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
const params = createDiscordCommandParams("/unfocus");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("Thread unfocused");
|
||||
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "thread-1",
|
||||
reason: "manual",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("/focus rejects rebinding when the thread is focused by another user", async () => {
|
||||
@@ -441,7 +428,9 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
const text = await runAgentsCommandAndText();
|
||||
const params = createDiscordCommandParams("/agents");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(text).toContain("agents:");
|
||||
expect(text).toContain("thread:thread-1");
|
||||
@@ -475,7 +464,9 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
const text = await runAgentsCommandAndText();
|
||||
const params = createDiscordCommandParams("/agents");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expectAgentListContainsThreadBinding(text, "persistent-1", "thread-persistent-1");
|
||||
});
|
||||
|
||||
@@ -26,25 +26,21 @@ function createDispatcher(): ReplyDispatcher {
|
||||
};
|
||||
}
|
||||
|
||||
function createCoordinator(onReplyStart?: (...args: unknown[]) => Promise<void>) {
|
||||
return createAcpDispatchDeliveryCoordinator({
|
||||
cfg: createAcpTestConfig(),
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
}),
|
||||
dispatcher: createDispatcher(),
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
...(onReplyStart ? { onReplyStart } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
it("starts reply lifecycle only once when called directly and through deliver", async () => {
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const coordinator = createCoordinator(onReplyStart);
|
||||
const coordinator = createAcpDispatchDeliveryCoordinator({
|
||||
cfg: createAcpTestConfig(),
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
}),
|
||||
dispatcher: createDispatcher(),
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
onReplyStart,
|
||||
});
|
||||
|
||||
await coordinator.startReplyLifecycle();
|
||||
await coordinator.deliver("final", { text: "hello" });
|
||||
@@ -56,7 +52,18 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
|
||||
it("starts reply lifecycle once when deliver triggers first", async () => {
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const coordinator = createCoordinator(onReplyStart);
|
||||
const coordinator = createAcpDispatchDeliveryCoordinator({
|
||||
cfg: createAcpTestConfig(),
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
}),
|
||||
dispatcher: createDispatcher(),
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
onReplyStart,
|
||||
});
|
||||
|
||||
await coordinator.deliver("final", { text: "hello" });
|
||||
await coordinator.startReplyLifecycle();
|
||||
@@ -66,7 +73,18 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
|
||||
it("does not start reply lifecycle for empty payload delivery", async () => {
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const coordinator = createCoordinator(onReplyStart);
|
||||
const coordinator = createAcpDispatchDeliveryCoordinator({
|
||||
cfg: createAcpTestConfig(),
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
}),
|
||||
dispatcher: createDispatcher(),
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
onReplyStart,
|
||||
});
|
||||
|
||||
await coordinator.deliver("final", {});
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({
|
||||
}));
|
||||
|
||||
const { tryDispatchAcpReply } = await import("./dispatch-acp.js");
|
||||
const sessionKey = "agent:codex-acp:session-1";
|
||||
|
||||
function createDispatcher(): {
|
||||
dispatcher: ReplyDispatcher;
|
||||
@@ -106,7 +105,7 @@ function createDispatcher(): {
|
||||
function setReadyAcpResolution() {
|
||||
managerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
sessionKey,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
meta: createAcpSessionMeta(),
|
||||
});
|
||||
}
|
||||
@@ -125,84 +124,6 @@ function createAcpConfigWithVisibleToolTags(): OpenClawConfig {
|
||||
});
|
||||
}
|
||||
|
||||
async function runDispatch(params: {
|
||||
bodyForAgent: string;
|
||||
cfg?: OpenClawConfig;
|
||||
dispatcher?: ReplyDispatcher;
|
||||
shouldRouteToOriginating?: boolean;
|
||||
onReplyStart?: () => void;
|
||||
}) {
|
||||
return tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: sessionKey,
|
||||
BodyForAgent: params.bodyForAgent,
|
||||
}),
|
||||
cfg: params.cfg ?? createAcpTestConfig(),
|
||||
dispatcher: params.dispatcher ?? createDispatcher().dispatcher,
|
||||
sessionKey,
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: params.shouldRouteToOriginating ?? false,
|
||||
...(params.shouldRouteToOriginating
|
||||
? { originatingChannel: "telegram", originatingTo: "telegram:thread-1" }
|
||||
: {}),
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
...(params.onReplyStart ? { onReplyStart: params.onReplyStart } : {}),
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
async function emitToolLifecycleEvents(
|
||||
onEvent: (event: unknown) => Promise<void>,
|
||||
toolCallId: string,
|
||||
) {
|
||||
await onEvent({
|
||||
type: "tool_call",
|
||||
tag: "tool_call",
|
||||
toolCallId,
|
||||
status: "in_progress",
|
||||
title: "Run command",
|
||||
text: "Run command (in_progress)",
|
||||
});
|
||||
await onEvent({
|
||||
type: "tool_call",
|
||||
tag: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "completed",
|
||||
title: "Run command",
|
||||
text: "Run command (completed)",
|
||||
});
|
||||
await onEvent({ type: "done" });
|
||||
}
|
||||
|
||||
function mockToolLifecycleTurn(toolCallId: string) {
|
||||
managerMocks.runTurn.mockImplementation(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await emitToolLifecycleEvents(onEvent, toolCallId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mockVisibleTextTurn(text = "visible") {
|
||||
managerMocks.runTurn.mockImplementationOnce(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchVisibleTurn(onReplyStart: () => void) {
|
||||
await runDispatch({
|
||||
bodyForAgent: "visible",
|
||||
dispatcher: createDispatcher().dispatcher,
|
||||
onReplyStart,
|
||||
});
|
||||
}
|
||||
|
||||
describe("tryDispatchAcpReply", () => {
|
||||
beforeEach(() => {
|
||||
managerMocks.resolveSession.mockReset();
|
||||
@@ -239,10 +160,24 @@ describe("tryDispatchAcpReply", () => {
|
||||
);
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
const result = await runDispatch({
|
||||
bodyForAgent: "reply",
|
||||
const result = await tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
BodyForAgent: "reply",
|
||||
}),
|
||||
cfg: createAcpTestConfig(),
|
||||
dispatcher,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: true,
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "telegram:thread-1",
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result?.counts.block).toBe(1);
|
||||
@@ -257,15 +192,48 @@ describe("tryDispatchAcpReply", () => {
|
||||
|
||||
it("edits ACP tool lifecycle updates in place when supported", async () => {
|
||||
setReadyAcpResolution();
|
||||
mockToolLifecycleTurn("call-1");
|
||||
managerMocks.runTurn.mockImplementation(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await onEvent({
|
||||
type: "tool_call",
|
||||
tag: "tool_call",
|
||||
toolCallId: "call-1",
|
||||
status: "in_progress",
|
||||
title: "Run command",
|
||||
text: "Run command (in_progress)",
|
||||
});
|
||||
await onEvent({
|
||||
type: "tool_call",
|
||||
tag: "tool_call_update",
|
||||
toolCallId: "call-1",
|
||||
status: "completed",
|
||||
title: "Run command",
|
||||
text: "Run command (completed)",
|
||||
});
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
routeMocks.routeReply.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" });
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
await runDispatch({
|
||||
bodyForAgent: "run tool",
|
||||
await tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
BodyForAgent: "run tool",
|
||||
}),
|
||||
cfg: createAcpConfigWithVisibleToolTags(),
|
||||
dispatcher,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: true,
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "telegram:thread-1",
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
|
||||
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
||||
@@ -281,18 +249,51 @@ describe("tryDispatchAcpReply", () => {
|
||||
|
||||
it("falls back to new tool message when edit fails", async () => {
|
||||
setReadyAcpResolution();
|
||||
mockToolLifecycleTurn("call-2");
|
||||
managerMocks.runTurn.mockImplementation(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await onEvent({
|
||||
type: "tool_call",
|
||||
tag: "tool_call",
|
||||
toolCallId: "call-2",
|
||||
status: "in_progress",
|
||||
title: "Run command",
|
||||
text: "Run command (in_progress)",
|
||||
});
|
||||
await onEvent({
|
||||
type: "tool_call",
|
||||
tag: "tool_call_update",
|
||||
toolCallId: "call-2",
|
||||
status: "completed",
|
||||
title: "Run command",
|
||||
text: "Run command (completed)",
|
||||
});
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
routeMocks.routeReply
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" });
|
||||
messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported"));
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
await runDispatch({
|
||||
bodyForAgent: "run tool",
|
||||
await tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
BodyForAgent: "run tool",
|
||||
}),
|
||||
cfg: createAcpConfigWithVisibleToolTags(),
|
||||
dispatcher,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: true,
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "telegram:thread-1",
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
|
||||
expect(messageActionMocks.runMessageAction).toHaveBeenCalledTimes(1);
|
||||
@@ -316,15 +317,50 @@ describe("tryDispatchAcpReply", () => {
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
await runDispatch({
|
||||
bodyForAgent: "hidden",
|
||||
await tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
BodyForAgent: "hidden",
|
||||
}),
|
||||
cfg: createAcpTestConfig(),
|
||||
dispatcher,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
onReplyStart,
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockVisibleTextTurn();
|
||||
await dispatchVisibleTurn(onReplyStart);
|
||||
managerMocks.runTurn.mockImplementationOnce(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await onEvent({ type: "text_delta", text: "visible", tag: "agent_message_chunk" });
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
await tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
BodyForAgent: "visible",
|
||||
}),
|
||||
cfg: createAcpTestConfig(),
|
||||
dispatcher: createDispatcher().dispatcher,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
onReplyStart,
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -332,8 +368,31 @@ describe("tryDispatchAcpReply", () => {
|
||||
setReadyAcpResolution();
|
||||
const onReplyStart = vi.fn();
|
||||
|
||||
mockVisibleTextTurn();
|
||||
await dispatchVisibleTurn(onReplyStart);
|
||||
managerMocks.runTurn.mockImplementationOnce(
|
||||
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
||||
await onEvent({ type: "text_delta", text: "visible", tag: "agent_message_chunk" });
|
||||
await onEvent({ type: "done" });
|
||||
},
|
||||
);
|
||||
|
||||
await tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
BodyForAgent: "visible",
|
||||
}),
|
||||
cfg: createAcpTestConfig(),
|
||||
dispatcher: createDispatcher().dispatcher,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
onReplyStart,
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -343,10 +402,23 @@ describe("tryDispatchAcpReply", () => {
|
||||
const onReplyStart = vi.fn();
|
||||
const { dispatcher } = createDispatcher();
|
||||
|
||||
await runDispatch({
|
||||
bodyForAgent: " ",
|
||||
await tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
BodyForAgent: " ",
|
||||
}),
|
||||
cfg: createAcpTestConfig(),
|
||||
dispatcher,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
onReplyStart,
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
|
||||
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
||||
@@ -360,9 +432,22 @@ describe("tryDispatchAcpReply", () => {
|
||||
);
|
||||
const { dispatcher } = createDispatcher();
|
||||
|
||||
await runDispatch({
|
||||
bodyForAgent: "test",
|
||||
await tryDispatchAcpReply({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
BodyForAgent: "test",
|
||||
}),
|
||||
cfg: createAcpTestConfig(),
|
||||
dispatcher,
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
});
|
||||
|
||||
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
||||
|
||||
@@ -113,10 +113,6 @@ function mockCompactionRun(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function createAsyncReplySpy() {
|
||||
return vi.fn(async () => {});
|
||||
}
|
||||
|
||||
describe("createFollowupRunner compaction", () => {
|
||||
it("adds verbose auto-compaction notice and tracks count", async () => {
|
||||
const storePath = path.join(
|
||||
@@ -185,97 +181,92 @@ describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function runMessagingCase(params: {
|
||||
agentResult: Record<string, unknown>;
|
||||
queued?: FollowupRun;
|
||||
runnerOverrides?: Partial<{
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath: string;
|
||||
}>;
|
||||
}) {
|
||||
const onBlockReply = createAsyncReplySpy();
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
meta: {},
|
||||
...params.agentResult,
|
||||
});
|
||||
const runner = createMessagingDedupeRunner(onBlockReply, params.runnerOverrides);
|
||||
await runner(params.queued ?? baseQueuedRun());
|
||||
return { onBlockReply };
|
||||
}
|
||||
|
||||
function makeTextReplyDedupeResult(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("drops payloads already sent via messaging tool", async () => {
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["hello world!"],
|
||||
},
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["hello world!"],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner(baseQueuedRun());
|
||||
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers payloads when not duplicates", async () => {
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: makeTextReplyDedupeResult(),
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner(baseQueuedRun());
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses replies when a messaging tool sent via the same provider + target", async () => {
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
...makeTextReplyDedupeResult(),
|
||||
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
|
||||
},
|
||||
queued: baseQueuedRun("slack"),
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner(baseQueuedRun("slack"));
|
||||
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses replies when provider is synthetic but originating channel matches", async () => {
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
...makeTextReplyDedupeResult(),
|
||||
messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }],
|
||||
},
|
||||
queued: {
|
||||
...baseQueuedRun("heartbeat"),
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "268300329",
|
||||
} as FollowupRun,
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner({
|
||||
...baseQueuedRun("heartbeat"),
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "268300329",
|
||||
} as FollowupRun);
|
||||
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not suppress replies for same target when account differs", async () => {
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
...makeTextReplyDedupeResult(),
|
||||
messagingToolSentTargets: [
|
||||
{ tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" },
|
||||
],
|
||||
},
|
||||
queued: {
|
||||
...baseQueuedRun("heartbeat"),
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "268300329",
|
||||
originatingAccountId: "personal",
|
||||
} as FollowupRun,
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [
|
||||
{ tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" },
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner({
|
||||
...baseQueuedRun("heartbeat"),
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "268300329",
|
||||
originatingAccountId: "personal",
|
||||
} as FollowupRun);
|
||||
|
||||
expect(routeReplyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
@@ -287,25 +278,33 @@ describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
});
|
||||
|
||||
it("drops media URL from payload when messaging tool already sent it", async () => {
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
payloads: [{ mediaUrl: "/tmp/img.png" }],
|
||||
messagingToolSentMediaUrls: ["/tmp/img.png"],
|
||||
},
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ mediaUrl: "/tmp/img.png" }],
|
||||
messagingToolSentMediaUrls: ["/tmp/img.png"],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner(baseQueuedRun());
|
||||
|
||||
// Media stripped → payload becomes non-renderable → not delivered.
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers media payload when not a duplicate", async () => {
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
payloads: [{ mediaUrl: "/tmp/img.png" }],
|
||||
messagingToolSentMediaUrls: ["/tmp/other.png"],
|
||||
},
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ mediaUrl: "/tmp/img.png" }],
|
||||
messagingToolSentMediaUrls: ["/tmp/other.png"],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner(baseQueuedRun());
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -319,28 +318,30 @@ describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
...makeTextReplyDedupeResult(),
|
||||
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
usage: { input: 1_000, output: 50 },
|
||||
lastCallUsage: { input: 400, output: 20 },
|
||||
model: "claude-opus-4-5",
|
||||
provider: "anthropic",
|
||||
},
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
usage: { input: 1_000, output: 50 },
|
||||
lastCallUsage: { input: 400, output: 20 },
|
||||
model: "claude-opus-4-5",
|
||||
provider: "anthropic",
|
||||
},
|
||||
},
|
||||
runnerOverrides: {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
queued: baseQueuedRun("slack"),
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply, {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
|
||||
await runner(baseQueuedRun("slack"));
|
||||
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
// totalTokens should reflect the last call usage snapshot, not the accumulated input.
|
||||
@@ -352,36 +353,46 @@ describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
});
|
||||
|
||||
it("does not fall back to dispatcher when cross-channel origin routing fails", async () => {
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
meta: {},
|
||||
});
|
||||
routeReplyMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: "forced route failure",
|
||||
});
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: { payloads: [{ text: "hello world!" }] },
|
||||
queued: {
|
||||
...baseQueuedRun("webchat"),
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:C1",
|
||||
} as FollowupRun,
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner({
|
||||
...baseQueuedRun("webchat"),
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:C1",
|
||||
} as FollowupRun);
|
||||
|
||||
expect(routeReplyMock).toHaveBeenCalled();
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to dispatcher when same-channel origin routing fails", async () => {
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
meta: {},
|
||||
});
|
||||
routeReplyMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: "outbound adapter unavailable",
|
||||
});
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: { payloads: [{ text: "hello world!" }] },
|
||||
queued: {
|
||||
...baseQueuedRun(" Feishu "),
|
||||
originatingChannel: "FEISHU",
|
||||
originatingTo: "ou_abc123",
|
||||
} as FollowupRun,
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner({
|
||||
...baseQueuedRun(" Feishu "),
|
||||
originatingChannel: "FEISHU",
|
||||
originatingTo: "ou_abc123",
|
||||
} as FollowupRun);
|
||||
|
||||
expect(routeReplyMock).toHaveBeenCalled();
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
@@ -389,17 +400,22 @@ describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
});
|
||||
|
||||
it("routes followups with originating account/thread metadata", async () => {
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: { payloads: [{ text: "hello world!" }] },
|
||||
queued: {
|
||||
...baseQueuedRun("webchat"),
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:C1",
|
||||
originatingAccountId: "work",
|
||||
originatingThreadId: "1739142736.000100",
|
||||
} as FollowupRun,
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createMessagingDedupeRunner(onBlockReply);
|
||||
|
||||
await runner({
|
||||
...baseQueuedRun("webchat"),
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:C1",
|
||||
originatingAccountId: "work",
|
||||
originatingThreadId: "1739142736.000100",
|
||||
} as FollowupRun);
|
||||
|
||||
expect(routeReplyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "discord",
|
||||
@@ -413,37 +429,44 @@ describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
});
|
||||
|
||||
describe("createFollowupRunner typing cleanup", () => {
|
||||
async function runTypingCase(agentResult: Record<string, unknown>) {
|
||||
it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => {
|
||||
const typing = createMockTypingController();
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "NO_REPLY" }],
|
||||
meta: {},
|
||||
...agentResult,
|
||||
});
|
||||
|
||||
const runner = createFollowupRunner({
|
||||
opts: { onBlockReply: createAsyncReplySpy() },
|
||||
opts: { onBlockReply: vi.fn(async () => {}) },
|
||||
typing,
|
||||
typingMode: "instant",
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
|
||||
await runner(baseQueuedRun());
|
||||
return typing;
|
||||
}
|
||||
|
||||
function expectTypingCleanup(typing: ReturnType<typeof createMockTypingController>) {
|
||||
expect(typing.markRunComplete).toHaveBeenCalled();
|
||||
expect(typing.markDispatchIdle).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => {
|
||||
const typing = await runTypingCase({ payloads: [{ text: "NO_REPLY" }] });
|
||||
expectTypingCleanup(typing);
|
||||
});
|
||||
|
||||
it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => {
|
||||
const typing = await runTypingCase({ payloads: [] });
|
||||
expectTypingCleanup(typing);
|
||||
const typing = createMockTypingController();
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createFollowupRunner({
|
||||
opts: { onBlockReply: vi.fn(async () => {}) },
|
||||
typing,
|
||||
typingMode: "instant",
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
|
||||
await runner(baseQueuedRun());
|
||||
|
||||
expect(typing.markRunComplete).toHaveBeenCalled();
|
||||
expect(typing.markDispatchIdle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls both markRunComplete and markDispatchIdle on agent error", async () => {
|
||||
@@ -459,7 +482,8 @@ describe("createFollowupRunner typing cleanup", () => {
|
||||
|
||||
await runner(baseQueuedRun());
|
||||
|
||||
expectTypingCleanup(typing);
|
||||
expect(typing.markRunComplete).toHaveBeenCalled();
|
||||
expect(typing.markDispatchIdle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => {
|
||||
@@ -480,7 +504,8 @@ describe("createFollowupRunner typing cleanup", () => {
|
||||
await runner(baseQueuedRun());
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalled();
|
||||
expectTypingCleanup(typing);
|
||||
expect(typing.markRunComplete).toHaveBeenCalled();
|
||||
expect(typing.markDispatchIdle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -105,56 +105,6 @@ function buildNativeResetContext(): MsgContext {
|
||||
};
|
||||
}
|
||||
|
||||
function createContinueDirectivesResult(resetHookTriggered: boolean) {
|
||||
return {
|
||||
kind: "continue" as const,
|
||||
result: {
|
||||
commandSource: "/new",
|
||||
command: {
|
||||
surface: "telegram",
|
||||
channel: "telegram",
|
||||
channelId: "telegram",
|
||||
ownerList: [],
|
||||
senderIsOwner: true,
|
||||
isAuthorizedSender: true,
|
||||
senderId: "123",
|
||||
abortKey: "telegram:slash:123",
|
||||
rawBodyNormalized: "/new",
|
||||
commandBodyNormalized: "/new",
|
||||
from: "telegram:123",
|
||||
to: "slash:123",
|
||||
resetHookTriggered,
|
||||
},
|
||||
allowTextCommands: true,
|
||||
skillCommands: [],
|
||||
directives: {},
|
||||
cleanedBody: "/new",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
elevatedFailures: [],
|
||||
defaultActivation: "always",
|
||||
resolvedThinkLevel: undefined,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolvedElevatedLevel: "off",
|
||||
execOverrides: undefined,
|
||||
blockStreamingEnabled: false,
|
||||
blockReplyChunking: undefined,
|
||||
resolvedBlockStreamingBreak: undefined,
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
modelState: {
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
},
|
||||
contextTokens: 0,
|
||||
inlineStatusRequested: false,
|
||||
directiveAck: undefined,
|
||||
perMessageQueueMode: undefined,
|
||||
perMessageQueueOptions: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getReplyFromConfig reset-hook fallback", () => {
|
||||
beforeEach(() => {
|
||||
mocks.resolveReplyDirectives.mockReset();
|
||||
@@ -181,7 +131,53 @@ describe("getReplyFromConfig reset-hook fallback", () => {
|
||||
bodyStripped: "",
|
||||
});
|
||||
|
||||
mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult(false));
|
||||
mocks.resolveReplyDirectives.mockResolvedValue({
|
||||
kind: "continue",
|
||||
result: {
|
||||
commandSource: "/new",
|
||||
command: {
|
||||
surface: "telegram",
|
||||
channel: "telegram",
|
||||
channelId: "telegram",
|
||||
ownerList: [],
|
||||
senderIsOwner: true,
|
||||
isAuthorizedSender: true,
|
||||
senderId: "123",
|
||||
abortKey: "telegram:slash:123",
|
||||
rawBodyNormalized: "/new",
|
||||
commandBodyNormalized: "/new",
|
||||
from: "telegram:123",
|
||||
to: "slash:123",
|
||||
resetHookTriggered: false,
|
||||
},
|
||||
allowTextCommands: true,
|
||||
skillCommands: [],
|
||||
directives: {},
|
||||
cleanedBody: "/new",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
elevatedFailures: [],
|
||||
defaultActivation: "always",
|
||||
resolvedThinkLevel: undefined,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolvedElevatedLevel: "off",
|
||||
execOverrides: undefined,
|
||||
blockStreamingEnabled: false,
|
||||
blockReplyChunking: undefined,
|
||||
resolvedBlockStreamingBreak: undefined,
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
modelState: {
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
},
|
||||
contextTokens: 0,
|
||||
inlineStatusRequested: false,
|
||||
directiveAck: undefined,
|
||||
perMessageQueueMode: undefined,
|
||||
perMessageQueueOptions: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("emits reset hooks when inline actions return early without marking resetHookTriggered", async () => {
|
||||
@@ -200,7 +196,53 @@ describe("getReplyFromConfig reset-hook fallback", () => {
|
||||
|
||||
it("does not emit fallback hooks when resetHookTriggered is already set", async () => {
|
||||
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined });
|
||||
mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult(true));
|
||||
mocks.resolveReplyDirectives.mockResolvedValue({
|
||||
kind: "continue",
|
||||
result: {
|
||||
commandSource: "/new",
|
||||
command: {
|
||||
surface: "telegram",
|
||||
channel: "telegram",
|
||||
channelId: "telegram",
|
||||
ownerList: [],
|
||||
senderIsOwner: true,
|
||||
isAuthorizedSender: true,
|
||||
senderId: "123",
|
||||
abortKey: "telegram:slash:123",
|
||||
rawBodyNormalized: "/new",
|
||||
commandBodyNormalized: "/new",
|
||||
from: "telegram:123",
|
||||
to: "slash:123",
|
||||
resetHookTriggered: true,
|
||||
},
|
||||
allowTextCommands: true,
|
||||
skillCommands: [],
|
||||
directives: {},
|
||||
cleanedBody: "/new",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
elevatedFailures: [],
|
||||
defaultActivation: "always",
|
||||
resolvedThinkLevel: undefined,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolvedElevatedLevel: "off",
|
||||
execOverrides: undefined,
|
||||
blockStreamingEnabled: false,
|
||||
blockReplyChunking: undefined,
|
||||
resolvedBlockStreamingBreak: undefined,
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
modelState: {
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
},
|
||||
contextTokens: 0,
|
||||
inlineStatusRequested: false,
|
||||
directiveAck: undefined,
|
||||
perMessageQueueMode: undefined,
|
||||
perMessageQueueOptions: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await getReplyFromConfig(buildNativeResetContext(), undefined, {});
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ function buildResolvedConfig(): ResolvedBrowserConfig {
|
||||
enabled: true,
|
||||
evaluateEnabled: false,
|
||||
controlPort: 0,
|
||||
cdpPortRangeStart: 18800,
|
||||
cdpPortRangeEnd: 18899,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
|
||||
@@ -285,16 +285,6 @@ export async function launchOpenClawChrome(
|
||||
}
|
||||
|
||||
const proc = spawnOnce();
|
||||
|
||||
// Collect stderr for diagnostics in case Chrome fails to start.
|
||||
// The listener is removed on success to avoid unbounded memory growth
|
||||
// from a long-lived Chrome process that emits periodic warnings.
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk);
|
||||
};
|
||||
proc.stderr?.on("data", onStderr);
|
||||
|
||||
// Wait for CDP to come up.
|
||||
const readyDeadline = Date.now() + 15_000;
|
||||
while (Date.now() < readyDeadline) {
|
||||
@@ -305,26 +295,16 @@ export async function launchOpenClawChrome(
|
||||
}
|
||||
|
||||
if (!(await isChromeReachable(profile.cdpUrl, 500))) {
|
||||
const stderrOutput = Buffer.concat(stderrChunks).toString("utf8").trim();
|
||||
const stderrHint = stderrOutput ? `\nChrome stderr:\n${stderrOutput.slice(0, 2000)}` : "";
|
||||
const sandboxHint =
|
||||
process.platform === "linux" && !resolved.noSandbox
|
||||
? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config."
|
||||
: "";
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".${sandboxHint}${stderrHint}`,
|
||||
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// Chrome started successfully — detach the stderr listener and release the buffer.
|
||||
proc.stderr?.off("data", onStderr);
|
||||
stderrChunks.length = 0;
|
||||
|
||||
const pid = proc.pid ?? -1;
|
||||
log.info(
|
||||
`🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
|
||||
|
||||
@@ -55,22 +55,6 @@ describe("browser config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("supports overriding the local CDP auto-allocation range start", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
cdpPortRangeStart: 19000,
|
||||
});
|
||||
const openclaw = resolveProfile(resolved, "openclaw");
|
||||
expect(resolved.cdpPortRangeStart).toBe(19000);
|
||||
expect(openclaw?.cdpPort).toBe(19000);
|
||||
expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:19000");
|
||||
});
|
||||
|
||||
it("rejects cdpPortRangeStart values that overflow the CDP range window", () => {
|
||||
expect(() => resolveBrowserConfig({ cdpPortRangeStart: 65535 })).toThrow(
|
||||
/cdpPortRangeStart .* too high/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes hex colors", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
color: "ff4500",
|
||||
|
||||
@@ -20,11 +20,11 @@ export type ResolvedBrowserConfig = {
|
||||
enabled: boolean;
|
||||
evaluateEnabled: boolean;
|
||||
controlPort: number;
|
||||
cdpPortRangeStart: number;
|
||||
cdpPortRangeEnd: number;
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
cdpPortRangeStart?: number;
|
||||
cdpPortRangeEnd?: number;
|
||||
remoteCdpTimeoutMs: number;
|
||||
remoteCdpHandshakeTimeoutMs: number;
|
||||
color: string;
|
||||
@@ -65,27 +65,6 @@ function normalizeTimeoutMs(raw: number | undefined, fallback: number) {
|
||||
return value < 0 ? fallback : value;
|
||||
}
|
||||
|
||||
function resolveCdpPortRangeStart(
|
||||
rawStart: number | undefined,
|
||||
fallbackStart: number,
|
||||
rangeSpan: number,
|
||||
) {
|
||||
const start =
|
||||
typeof rawStart === "number" && Number.isFinite(rawStart)
|
||||
? Math.floor(rawStart)
|
||||
: fallbackStart;
|
||||
if (start < 1 || start > 65535) {
|
||||
throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`);
|
||||
}
|
||||
const maxStart = 65535 - rangeSpan;
|
||||
if (start > maxStart) {
|
||||
throw new Error(
|
||||
`browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`,
|
||||
);
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function normalizeStringList(raw: string[] | undefined): string[] | undefined {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return undefined;
|
||||
@@ -216,13 +195,6 @@ export function resolveBrowserConfig(
|
||||
);
|
||||
|
||||
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
|
||||
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
|
||||
const cdpPortRangeStart = resolveCdpPortRangeStart(
|
||||
cfg?.cdpPortRangeStart,
|
||||
derivedCdpRange.start,
|
||||
cdpRangeSpan,
|
||||
);
|
||||
const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan;
|
||||
|
||||
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
|
||||
let cdpInfo:
|
||||
@@ -258,7 +230,7 @@ export function resolveBrowserConfig(
|
||||
// Use legacy cdpUrl port for backward compatibility when no profiles configured
|
||||
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
|
||||
const profiles = ensureDefaultChromeExtensionProfile(
|
||||
ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart),
|
||||
ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, derivedCdpRange.start),
|
||||
controlPort,
|
||||
);
|
||||
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
||||
@@ -284,11 +256,11 @@ export function resolveBrowserConfig(
|
||||
enabled,
|
||||
evaluateEnabled,
|
||||
controlPort,
|
||||
cdpPortRangeStart,
|
||||
cdpPortRangeEnd,
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
cdpPortRangeStart: derivedCdpRange.start,
|
||||
cdpPortRangeEnd: derivedCdpRange.end,
|
||||
remoteCdpTimeoutMs,
|
||||
remoteCdpHandshakeTimeoutMs,
|
||||
color: defaultColor,
|
||||
|
||||
@@ -730,148 +730,6 @@ describe("chrome extension relay server", () => {
|
||||
RELAY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("removes cached targets from /json/list when targetDestroyed arrives", async () => {
|
||||
const { ext } = await startRelayWithExtension();
|
||||
|
||||
ext.send(
|
||||
JSON.stringify({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.attachedToTarget",
|
||||
params: {
|
||||
sessionId: "cb-tab-1",
|
||||
targetInfo: {
|
||||
targetId: "t1",
|
||||
type: "page",
|
||||
title: "Example",
|
||||
url: "https://example.com",
|
||||
},
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForListMatch(
|
||||
async () =>
|
||||
(await fetch(`${cdpUrl}/json/list`, {
|
||||
headers: relayAuthHeaders(cdpUrl),
|
||||
}).then((r) => r.json())) as Array<{ id?: string }>,
|
||||
(list) => list.some((target) => target.id === "t1"),
|
||||
);
|
||||
|
||||
ext.send(
|
||||
JSON.stringify({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.targetDestroyed",
|
||||
params: { targetId: "t1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedList = await waitForListMatch(
|
||||
async () =>
|
||||
(await fetch(`${cdpUrl}/json/list`, {
|
||||
headers: relayAuthHeaders(cdpUrl),
|
||||
}).then((r) => r.json())) as Array<{ id?: string }>,
|
||||
(list) => list.every((target) => target.id !== "t1"),
|
||||
);
|
||||
|
||||
expect(updatedList.some((target) => target.id === "t1")).toBe(false);
|
||||
ext.close();
|
||||
});
|
||||
|
||||
it("prunes stale cached targets after target-not-found command errors", async () => {
|
||||
const { port, ext } = await startRelayWithExtension();
|
||||
const extQueue = createMessageQueue(ext);
|
||||
|
||||
ext.send(
|
||||
JSON.stringify({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.attachedToTarget",
|
||||
params: {
|
||||
sessionId: "cb-tab-1",
|
||||
targetInfo: {
|
||||
targetId: "t1",
|
||||
type: "page",
|
||||
title: "Example",
|
||||
url: "https://example.com",
|
||||
},
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForListMatch(
|
||||
async () =>
|
||||
(await fetch(`${cdpUrl}/json/list`, {
|
||||
headers: relayAuthHeaders(cdpUrl),
|
||||
}).then((r) => r.json())) as Array<{ id?: string }>,
|
||||
(list) => list.some((target) => target.id === "t1"),
|
||||
);
|
||||
|
||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
|
||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
|
||||
});
|
||||
await waitForOpen(cdp);
|
||||
const cdpQueue = createMessageQueue(cdp);
|
||||
|
||||
cdp.send(
|
||||
JSON.stringify({
|
||||
id: 77,
|
||||
method: "Runtime.evaluate",
|
||||
sessionId: "cb-tab-1",
|
||||
params: { expression: "1+1" },
|
||||
}),
|
||||
);
|
||||
|
||||
let forwardedId: number | null = null;
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const msg = JSON.parse(await extQueue.next()) as { method?: string; id?: number };
|
||||
if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") {
|
||||
forwardedId = msg.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(forwardedId).not.toBeNull();
|
||||
|
||||
ext.send(
|
||||
JSON.stringify({
|
||||
id: forwardedId,
|
||||
error: "No target with given id",
|
||||
}),
|
||||
);
|
||||
|
||||
let response: { id?: number; error?: { message?: string } } | null = null;
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const msg = JSON.parse(await cdpQueue.next()) as {
|
||||
id?: number;
|
||||
error?: { message?: string };
|
||||
};
|
||||
if (msg.id === 77) {
|
||||
response = msg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(response?.id).toBe(77);
|
||||
expect(response?.error?.message ?? "").toContain("No target with given id");
|
||||
|
||||
const updatedList = await waitForListMatch(
|
||||
async () =>
|
||||
(await fetch(`${cdpUrl}/json/list`, {
|
||||
headers: relayAuthHeaders(cdpUrl),
|
||||
}).then((r) => r.json())) as Array<{ id?: string }>,
|
||||
(list) => list.every((target) => target.id !== "t1"),
|
||||
);
|
||||
expect(updatedList.some((target) => target.id === "t1")).toBe(false);
|
||||
|
||||
cdp.close();
|
||||
ext.close();
|
||||
});
|
||||
|
||||
it("rebroadcasts attach when a session id is reused for a new target", async () => {
|
||||
const { port, ext } = await startRelayWithExtension();
|
||||
|
||||
|
||||
@@ -367,70 +367,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
ws.send(JSON.stringify(res));
|
||||
};
|
||||
|
||||
const dropConnectedTargetSession = (sessionId: string): ConnectedTarget | undefined => {
|
||||
const existing = connectedTargets.get(sessionId);
|
||||
if (!existing) {
|
||||
return undefined;
|
||||
}
|
||||
connectedTargets.delete(sessionId);
|
||||
return existing;
|
||||
};
|
||||
|
||||
const dropConnectedTargetsByTargetId = (targetId: string): ConnectedTarget[] => {
|
||||
const removed: ConnectedTarget[] = [];
|
||||
for (const [sessionId, target] of connectedTargets) {
|
||||
if (target.targetId !== targetId) {
|
||||
continue;
|
||||
}
|
||||
connectedTargets.delete(sessionId);
|
||||
removed.push(target);
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
const broadcastDetachedTarget = (target: ConnectedTarget, targetId?: string) => {
|
||||
broadcastToCdpClients({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: {
|
||||
sessionId: target.sessionId,
|
||||
targetId: targetId ?? target.targetId,
|
||||
},
|
||||
sessionId: target.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const isMissingTargetError = (err: unknown) => {
|
||||
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return (
|
||||
message.includes("target not found") ||
|
||||
message.includes("no target with given id") ||
|
||||
message.includes("session not found") ||
|
||||
message.includes("cannot find session")
|
||||
);
|
||||
};
|
||||
|
||||
const pruneStaleTargetsFromCommandFailure = (cmd: CdpCommand, err: unknown) => {
|
||||
if (!isMissingTargetError(err)) {
|
||||
return;
|
||||
}
|
||||
if (cmd.sessionId) {
|
||||
const removed = dropConnectedTargetSession(cmd.sessionId);
|
||||
if (removed) {
|
||||
broadcastDetachedTarget(removed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const params = (cmd.params ?? {}) as { targetId?: unknown };
|
||||
const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
const removedTargets = dropConnectedTargetsByTargetId(targetId);
|
||||
for (const removed of removedTargets) {
|
||||
broadcastDetachedTarget(removed, targetId);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTargetEventsForClient = (ws: WebSocket, mode: "autoAttach" | "discover") => {
|
||||
for (const target of connectedTargets.values()) {
|
||||
if (mode === "autoAttach") {
|
||||
@@ -826,18 +762,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
if (method === "Target.detachedFromTarget") {
|
||||
const detached = (params ?? {}) as DetachedFromTargetEvent;
|
||||
if (detached?.sessionId) {
|
||||
dropConnectedTargetSession(detached.sessionId);
|
||||
} else if (detached?.targetId) {
|
||||
dropConnectedTargetsByTargetId(detached.targetId);
|
||||
}
|
||||
broadcastToCdpClients({ method, params, sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "Target.targetDestroyed" || method === "Target.targetCrashed") {
|
||||
const targetEvent = (params ?? {}) as { targetId?: string };
|
||||
if (targetEvent.targetId) {
|
||||
dropConnectedTargetsByTargetId(targetEvent.targetId);
|
||||
connectedTargets.delete(detached.sessionId);
|
||||
}
|
||||
broadcastToCdpClients({ method, params, sessionId });
|
||||
return;
|
||||
@@ -946,7 +871,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
|
||||
sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result });
|
||||
} catch (err) {
|
||||
pruneStaleTargetsFromCommandFailure(cmd, err);
|
||||
sendResponseToCdp(ws, {
|
||||
id: cmd.id,
|
||||
sessionId: cmd.sessionId,
|
||||
|
||||
@@ -86,21 +86,6 @@ describe("BrowserProfilesService", () => {
|
||||
expect(writeConfigFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allocates from configured cdpPortRangeStart for new local profiles", async () => {
|
||||
const resolved = resolveBrowserConfig({ cdpPortRangeStart: 19000 });
|
||||
const { ctx, state } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { cdpPortRangeStart: 19000, profiles: {} } });
|
||||
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.createProfile({ name: "work" });
|
||||
|
||||
expect(result.cdpPort).toBe(19001);
|
||||
expect(result.isRemote).toBe(false);
|
||||
expect(state.resolved.profiles.work?.cdpPort).toBe(19001);
|
||||
expect(writeConfigFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts per-profile cdpUrl for remote Chrome", async () => {
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const { ctx } = createCtx(resolved);
|
||||
|
||||
@@ -78,21 +78,6 @@ describe("pw-tools-core", () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function expectAtomicDownloadSave(params: {
|
||||
saveAs: ReturnType<typeof vi.fn>;
|
||||
targetPath: string;
|
||||
tempDir: string;
|
||||
content: string;
|
||||
}) {
|
||||
const savedPath = params.saveAs.mock.calls[0]?.[0];
|
||||
expect(typeof savedPath).toBe("string");
|
||||
expect(savedPath).not.toBe(params.targetPath);
|
||||
expect(path.dirname(String(savedPath))).toBe(params.tempDir);
|
||||
expect(path.basename(String(savedPath))).toContain(".openclaw-output-");
|
||||
expect(path.basename(String(savedPath))).toContain(".part");
|
||||
expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content);
|
||||
}
|
||||
|
||||
it("waits for the next download and atomically finalizes explicit output paths", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const harness = createDownloadEventHarness();
|
||||
@@ -119,7 +104,13 @@ describe("pw-tools-core", () => {
|
||||
harness.trigger(download);
|
||||
|
||||
const res = await p;
|
||||
await expectAtomicDownloadSave({ saveAs, targetPath, tempDir, content: "file-content" });
|
||||
const savedPath = saveAs.mock.calls[0]?.[0];
|
||||
expect(typeof savedPath).toBe("string");
|
||||
expect(savedPath).not.toBe(targetPath);
|
||||
expect(path.dirname(String(savedPath))).toBe(tempDir);
|
||||
expect(path.basename(String(savedPath))).toContain(".openclaw-output-");
|
||||
expect(path.basename(String(savedPath))).toContain(".part");
|
||||
expect(await fs.readFile(targetPath, "utf8")).toBe("file-content");
|
||||
expect(res.path).toBe(targetPath);
|
||||
});
|
||||
});
|
||||
@@ -155,7 +146,13 @@ describe("pw-tools-core", () => {
|
||||
harness.trigger(download);
|
||||
|
||||
const res = await p;
|
||||
await expectAtomicDownloadSave({ saveAs, targetPath, tempDir, content: "report-content" });
|
||||
const savedPath = saveAs.mock.calls[0]?.[0];
|
||||
expect(typeof savedPath).toBe("string");
|
||||
expect(savedPath).not.toBe(targetPath);
|
||||
expect(path.dirname(String(savedPath))).toBe(tempDir);
|
||||
expect(path.basename(String(savedPath))).toContain(".openclaw-output-");
|
||||
expect(path.basename(String(savedPath))).toContain(".part");
|
||||
expect(await fs.readFile(targetPath, "utf8")).toBe("report-content");
|
||||
expect(res.path).toBe(targetPath);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,6 @@ function makeBrowserState(): BrowserServerState {
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlPort: 18791,
|
||||
cdpPortRangeStart: 18800,
|
||||
cdpPortRangeEnd: 18899,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
|
||||
@@ -24,8 +24,6 @@ function makeState(
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlPort: 18791,
|
||||
cdpPortRangeStart: 18800,
|
||||
cdpPortRangeEnd: 18899,
|
||||
cdpProtocol: profile === "remote" ? "https" : "http",
|
||||
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
|
||||
cdpIsLoopback: profile !== "remote",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createServer, type AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getFreePort } from "./test-port.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
controlPort: 0,
|
||||
@@ -12,13 +12,12 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
const browserConfig = {
|
||||
enabled: true,
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
browser: browserConfig,
|
||||
browser: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -59,6 +58,17 @@ vi.mock("./pw-ai-state.js", () => ({
|
||||
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||
await import("./server.js");
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const probe = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const addr = probe.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
return addr.port;
|
||||
}
|
||||
|
||||
describe("browser control auth bootstrap failures", () => {
|
||||
beforeEach(async () => {
|
||||
mocks.controlPort = await getFreePort();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer, type AddressInfo } from "node:net";
|
||||
import { fetch as realFetch } from "undici";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getFreePort } from "./test-port.js";
|
||||
|
||||
let testPort = 0;
|
||||
let prevGatewayPort: string | undefined;
|
||||
@@ -68,6 +68,17 @@ vi.mock("./server-context.js", async (importOriginal) => {
|
||||
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||
await import("./server.js");
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const probe = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const addr = probe.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
return addr.port;
|
||||
}
|
||||
|
||||
describe("browser control evaluate gating", () => {
|
||||
beforeEach(async () => {
|
||||
testPort = await getFreePort();
|
||||
|
||||
@@ -1,59 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTypingCallbacks } from "./typing.js";
|
||||
|
||||
type TypingCallbackOverrides = Partial<Parameters<typeof createTypingCallbacks>[0]>;
|
||||
type TypingHarnessStart = ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
type TypingHarnessError = ReturnType<typeof vi.fn<(err: unknown) => void>>;
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
async function withFakeTimers(run: () => Promise<void>) {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
function createTypingHarness(overrides: TypingCallbackOverrides = {}) {
|
||||
const start: TypingHarnessStart = vi.fn<() => Promise<void>>(async () => {});
|
||||
const stop: TypingHarnessStart = vi.fn<() => Promise<void>>(async () => {});
|
||||
const onStartError: TypingHarnessError = vi.fn<(err: unknown) => void>();
|
||||
const onStopError: TypingHarnessError = vi.fn<(err: unknown) => void>();
|
||||
|
||||
if (overrides.start) {
|
||||
start.mockImplementation(overrides.start);
|
||||
}
|
||||
if (overrides.stop) {
|
||||
stop.mockImplementation(overrides.stop);
|
||||
}
|
||||
if (overrides.onStartError) {
|
||||
onStartError.mockImplementation(overrides.onStartError);
|
||||
}
|
||||
if (overrides.onStopError) {
|
||||
onStopError.mockImplementation(overrides.onStopError);
|
||||
}
|
||||
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
onStopError,
|
||||
...(overrides.maxConsecutiveFailures !== undefined
|
||||
? { maxConsecutiveFailures: overrides.maxConsecutiveFailures }
|
||||
: {}),
|
||||
...(overrides.maxDurationMs !== undefined ? { maxDurationMs: overrides.maxDurationMs } : {}),
|
||||
});
|
||||
return { start, stop, onStartError, onStopError, callbacks };
|
||||
}
|
||||
|
||||
describe("createTypingCallbacks", () => {
|
||||
it("invokes start on reply start", async () => {
|
||||
const { start, onStartError, callbacks } = createTypingHarness();
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
@@ -62,9 +19,9 @@ describe("createTypingCallbacks", () => {
|
||||
});
|
||||
|
||||
it("reports start errors", async () => {
|
||||
const { onStartError, callbacks } = createTypingHarness({
|
||||
start: vi.fn().mockRejectedValue(new Error("fail")),
|
||||
});
|
||||
const start = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
@@ -72,9 +29,11 @@ describe("createTypingCallbacks", () => {
|
||||
});
|
||||
|
||||
it("invokes stop on idle and reports stop errors", async () => {
|
||||
const { stop, onStopError, callbacks } = createTypingHarness({
|
||||
stop: vi.fn().mockRejectedValue(new Error("stop")),
|
||||
});
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockRejectedValue(new Error("stop"));
|
||||
const onStartError = vi.fn();
|
||||
const onStopError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
|
||||
|
||||
callbacks.onIdle?.();
|
||||
await flushMicrotasks();
|
||||
@@ -84,8 +43,13 @@ describe("createTypingCallbacks", () => {
|
||||
});
|
||||
|
||||
it("sends typing keepalive pings until idle cleanup", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const { start, stop, callbacks } = createTypingHarness();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -104,14 +68,18 @@ describe("createTypingCallbacks", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(start).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("stops keepalive after consecutive start failures", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const { start, onStartError, callbacks } = createTypingHarness({
|
||||
start: vi.fn().mockRejectedValue(new Error("gone")),
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockRejectedValue(new Error("gone"));
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
||||
@@ -122,13 +90,19 @@ describe("createTypingCallbacks", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not restart keepalive when breaker trips on initial start", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const { start, onStartError, callbacks } = createTypingHarness({
|
||||
start: vi.fn().mockRejectedValue(new Error("gone")),
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockRejectedValue(new Error("gone"));
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
onStartError,
|
||||
maxConsecutiveFailures: 1,
|
||||
});
|
||||
|
||||
@@ -138,21 +112,28 @@ describe("createTypingCallbacks", () => {
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resets failure counter after a successful keepalive tick", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let callCount = 0;
|
||||
const { start, onStartError, callbacks } = createTypingHarness({
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
callCount += 1;
|
||||
if (callCount % 2 === 1) {
|
||||
throw new Error("flaky");
|
||||
}
|
||||
}),
|
||||
const start = vi.fn().mockImplementation(async () => {
|
||||
callCount += 1;
|
||||
if (callCount % 2 === 1) {
|
||||
throw new Error("flaky");
|
||||
}
|
||||
});
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
onStartError,
|
||||
maxConsecutiveFailures: 2,
|
||||
});
|
||||
|
||||
await callbacks.onReplyStart(); // fail
|
||||
await vi.advanceTimersByTimeAsync(3_000); // success
|
||||
await vi.advanceTimersByTimeAsync(3_000); // fail
|
||||
@@ -161,11 +142,16 @@ describe("createTypingCallbacks", () => {
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(5);
|
||||
expect(onStartError).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates stop across idle and cleanup", async () => {
|
||||
const { stop, callbacks } = createTypingHarness();
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
callbacks.onIdle?.();
|
||||
callbacks.onCleanup?.();
|
||||
@@ -175,8 +161,12 @@ describe("createTypingCallbacks", () => {
|
||||
});
|
||||
|
||||
it("does not restart keepalive after idle cleanup", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const { start, stop, callbacks } = createTypingHarness();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
@@ -189,15 +179,26 @@ describe("createTypingCallbacks", () => {
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// ========== TTL Safety Tests ==========
|
||||
describe("TTL safety", () => {
|
||||
it("auto-stops typing after maxDurationMs", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { start, stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
maxDurationMs: 10_000,
|
||||
});
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
@@ -211,13 +212,24 @@ describe("createTypingCallbacks", () => {
|
||||
expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("TTL exceeded"));
|
||||
|
||||
consoleWarn.mockRestore();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-stop if idle is called before TTL", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
maxDurationMs: 10_000,
|
||||
});
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
@@ -237,12 +249,18 @@ describe("createTypingCallbacks", () => {
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
consoleWarn.mockRestore();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default 60s TTL when not specified", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const { stop, callbacks } = createTypingHarness();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
@@ -253,24 +271,46 @@ describe("createTypingCallbacks", () => {
|
||||
// Should stop at 60s
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("disables TTL when maxDurationMs is 0", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 0 });
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
maxDurationMs: 0,
|
||||
});
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
// Should not auto-stop even after long time
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resets TTL timer on restart after idle", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({
|
||||
start,
|
||||
stop,
|
||||
onStartError,
|
||||
maxDurationMs: 10_000,
|
||||
});
|
||||
|
||||
// First start
|
||||
await callbacks.onReplyStart();
|
||||
@@ -290,7 +330,9 @@ describe("createTypingCallbacks", () => {
|
||||
|
||||
// Should not trigger stop again since it's closed
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callBrowserRequest: vi.fn(async (_opts: unknown, req: { path?: string }) =>
|
||||
req.path === "/"
|
||||
? {
|
||||
enabled: true,
|
||||
running: true,
|
||||
pid: 1,
|
||||
cdpPort: 18800,
|
||||
chosenBrowser: "chrome",
|
||||
userDataDir: "/tmp/openclaw",
|
||||
color: "blue",
|
||||
headless: true,
|
||||
attachOnly: false,
|
||||
}
|
||||
: {},
|
||||
),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./browser-cli-shared.js", () => ({
|
||||
callBrowserRequest: mocks.callBrowserRequest,
|
||||
}));
|
||||
|
||||
vi.mock("./cli-utils.js", () => ({
|
||||
runCommandWithRuntime: async (
|
||||
_runtime: unknown,
|
||||
action: () => Promise<void>,
|
||||
onError: (err: unknown) => void,
|
||||
) => {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: mocks.runtime,
|
||||
}));
|
||||
|
||||
describe("browser manage start timeout option", () => {
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
const browser = program
|
||||
.command("browser")
|
||||
.option("--browser-profile <name>", "Browser profile")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--timeout <ms>", "Timeout in ms", "30000");
|
||||
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
|
||||
registerBrowserManageCommands(browser, parentOpts);
|
||||
return program;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
mocks.runtime.log.mockClear();
|
||||
mocks.runtime.error.mockClear();
|
||||
mocks.runtime.exit.mockClear();
|
||||
});
|
||||
|
||||
it("uses parent --timeout for browser start instead of hardcoded 15s", async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["browser", "--timeout", "60000", "start"], { from: "user" });
|
||||
|
||||
const startCall = mocks.callBrowserRequest.mock.calls.find(
|
||||
(call) => ((call[1] ?? {}) as { path?: string }).path === "/start",
|
||||
) as [Record<string, unknown>, { path?: string }, unknown] | undefined;
|
||||
|
||||
expect(startCall).toBeDefined();
|
||||
expect(startCall?.[0]).toMatchObject({ timeout: "60000" });
|
||||
expect(startCall?.[2]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -34,11 +34,15 @@ async function runBrowserToggle(
|
||||
parent: BrowserParentOpts,
|
||||
params: { profile?: string; path: string },
|
||||
) {
|
||||
await callBrowserRequest(parent, {
|
||||
method: "POST",
|
||||
path: params.path,
|
||||
query: params.profile ? { profile: params.profile } : undefined,
|
||||
});
|
||||
await callBrowserRequest(
|
||||
parent,
|
||||
{
|
||||
method: "POST",
|
||||
path: params.path,
|
||||
query: params.profile ? { profile: params.profile } : undefined,
|
||||
},
|
||||
{ timeoutMs: 15000 },
|
||||
);
|
||||
const status = await fetchBrowserStatus(parent, params.profile);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
|
||||
@@ -156,49 +156,61 @@ async function expectCronEditWithScheduleLookupExit(
|
||||
).rejects.toThrow("__exit__:1");
|
||||
}
|
||||
|
||||
async function runCronRunAndCaptureExit(params: { ran: boolean }) {
|
||||
resetGatewayMock();
|
||||
callGatewayFromCli.mockImplementation(
|
||||
async (method: string, _opts: unknown, callParams?: unknown) => {
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (method === "cron.run") {
|
||||
return { ok: true, params: callParams, ran: params.ran };
|
||||
}
|
||||
return { ok: true, params: callParams };
|
||||
},
|
||||
);
|
||||
|
||||
const runtimeModule = await import("../runtime.js");
|
||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
||||
const originalExit = runtime.exit;
|
||||
const exitSpy = vi.fn();
|
||||
runtime.exit = exitSpy;
|
||||
try {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
||||
} finally {
|
||||
runtime.exit = originalExit;
|
||||
}
|
||||
return exitSpy;
|
||||
}
|
||||
|
||||
describe("cron cli", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "exits 0 for cron run when job executes successfully",
|
||||
ran: true,
|
||||
expectedExitCode: 0,
|
||||
},
|
||||
{
|
||||
name: "exits 1 for cron run when job does not execute",
|
||||
ran: false,
|
||||
expectedExitCode: 1,
|
||||
},
|
||||
])("$name", async ({ ran, expectedExitCode }) => {
|
||||
const exitSpy = await runCronRunAndCaptureExit({ ran });
|
||||
expect(exitSpy).toHaveBeenCalledWith(expectedExitCode);
|
||||
it("exits 0 for cron run when job executes successfully", async () => {
|
||||
resetGatewayMock();
|
||||
callGatewayFromCli.mockImplementation(
|
||||
async (method: string, _opts: unknown, params?: unknown) => {
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (method === "cron.run") {
|
||||
return { ok: true, params, ran: true };
|
||||
}
|
||||
return { ok: true, params };
|
||||
},
|
||||
);
|
||||
|
||||
const runtimeModule = await import("../runtime.js");
|
||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
||||
const originalExit = runtime.exit;
|
||||
const exitSpy = vi.fn();
|
||||
runtime.exit = exitSpy;
|
||||
try {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
runtime.exit = originalExit;
|
||||
}
|
||||
});
|
||||
|
||||
it("exits 1 for cron run when job does not execute", async () => {
|
||||
resetGatewayMock();
|
||||
callGatewayFromCli.mockImplementation(
|
||||
async (method: string, _opts: unknown, params?: unknown) => {
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (method === "cron.run") {
|
||||
return { ok: true, params, ran: false };
|
||||
}
|
||||
return { ok: true, params };
|
||||
},
|
||||
);
|
||||
|
||||
const runtimeModule = await import("../runtime.js");
|
||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
||||
const originalExit = runtime.exit;
|
||||
const exitSpy = vi.fn();
|
||||
runtime.exit = exitSpy;
|
||||
try {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
runtime.exit = originalExit;
|
||||
}
|
||||
});
|
||||
|
||||
it("trims model and thinking on cron add", { timeout: CRON_CLI_TEST_TIMEOUT_MS }, async () => {
|
||||
|
||||
@@ -28,20 +28,6 @@ function makeRuntime() {
|
||||
};
|
||||
}
|
||||
|
||||
async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
|
||||
const writes: string[] = [];
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
try {
|
||||
await run();
|
||||
return writes.join("");
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("ensureConfigReady", () => {
|
||||
async function loadEnsureConfigReady() {
|
||||
vi.resetModules();
|
||||
@@ -121,22 +107,36 @@ describe("ensureConfigReady", () => {
|
||||
});
|
||||
|
||||
it("prevents preflight stdout noise when suppression is enabled", async () => {
|
||||
const stdoutWrites: string[] = [];
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
||||
stdoutWrites.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||
process.stdout.write("Doctor warnings\n");
|
||||
});
|
||||
const output = await withCapturedStdout(async () => {
|
||||
try {
|
||||
await runEnsureConfigReady(["message"], true);
|
||||
});
|
||||
expect(output).not.toContain("Doctor warnings");
|
||||
expect(stdoutWrites.join("")).not.toContain("Doctor warnings");
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows preflight stdout noise when suppression is not enabled", async () => {
|
||||
const stdoutWrites: string[] = [];
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
||||
stdoutWrites.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||
process.stdout.write("Doctor warnings\n");
|
||||
});
|
||||
const output = await withCapturedStdout(async () => {
|
||||
try {
|
||||
await runEnsureConfigReady(["message"], false);
|
||||
});
|
||||
expect(output).toContain("Doctor warnings");
|
||||
expect(stdoutWrites.join("")).toContain("Doctor warnings");
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,31 +129,6 @@ function mockAcpManager(params: {
|
||||
} as unknown as ReturnType<typeof acpManagerModule.getAcpSessionManager>);
|
||||
}
|
||||
|
||||
async function runAcpSessionWithPolicyOverrides(params: {
|
||||
acpOverrides: Partial<NonNullable<OpenClawConfig["acp"]>>;
|
||||
resolveSession?: Parameters<typeof mockAcpManager>[0]["resolveSession"];
|
||||
}) {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfigWithAcpOverrides(home, storePath, params.acpOverrides);
|
||||
|
||||
const runTurn = vi.fn(async (_params: unknown) => {});
|
||||
mockAcpManager({
|
||||
runTurn: (input: unknown) => runTurn(input),
|
||||
...(params.resolveSession ? { resolveSession: params.resolveSession } : {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_DISPATCH_DISABLED",
|
||||
});
|
||||
expect(runTurn).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
describe("agentCommand ACP runtime routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -246,19 +221,50 @@ describe("agentCommand ACP runtime routing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks ACP turns when ACP is disabled by policy",
|
||||
acpOverrides: { enabled: false } satisfies Partial<NonNullable<OpenClawConfig["acp"]>>,
|
||||
},
|
||||
{
|
||||
name: "blocks ACP turns when ACP dispatch is disabled by policy",
|
||||
acpOverrides: {
|
||||
it("blocks ACP turns when ACP is disabled by policy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfigWithAcpOverrides(home, storePath, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const runTurn = vi.fn(async (_params: unknown) => {});
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_DISPATCH_DISABLED",
|
||||
});
|
||||
expect(runTurn).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks ACP turns when ACP dispatch is disabled by policy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfigWithAcpOverrides(home, storePath, {
|
||||
dispatch: { enabled: false },
|
||||
} satisfies Partial<NonNullable<OpenClawConfig["acp"]>>,
|
||||
},
|
||||
])("$name", async ({ acpOverrides }) => {
|
||||
await runAcpSessionWithPolicyOverrides({ acpOverrides });
|
||||
});
|
||||
|
||||
const runTurn = vi.fn(async (_params: unknown) => {});
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_DISPATCH_DISABLED",
|
||||
});
|
||||
expect(runTurn).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks ACP turns when ACP agent is disallowed by policy", async () => {
|
||||
|
||||
@@ -93,20 +93,6 @@ async function runWithDefaultAgentConfig(params: {
|
||||
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
}
|
||||
|
||||
async function runEmbeddedWithTempConfig(params: {
|
||||
args: Parameters<typeof agentCommand>[0];
|
||||
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
|
||||
telegramOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>;
|
||||
agentsList?: Array<{ id: string; default?: boolean }>;
|
||||
}) {
|
||||
return withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, params.agentOverrides, params.telegramOverrides, params.agentsList);
|
||||
await agentCommand(params.args, runtime);
|
||||
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
});
|
||||
}
|
||||
|
||||
function writeSessionStoreSeed(
|
||||
storePath: string,
|
||||
sessions: Record<string, Record<string, unknown>>,
|
||||
@@ -115,149 +101,54 @@ function writeSessionStoreSeed(
|
||||
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
|
||||
}
|
||||
|
||||
function createDefaultAgentResult(params?: {
|
||||
payloads?: Array<Record<string, unknown>>;
|
||||
durationMs?: number;
|
||||
}) {
|
||||
return {
|
||||
payloads: params?.payloads ?? [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: params?.durationMs ?? 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getLastEmbeddedCall() {
|
||||
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
}
|
||||
|
||||
function expectLastRunProviderModel(provider: string, model: string): void {
|
||||
const callArgs = getLastEmbeddedCall();
|
||||
expect(callArgs?.provider).toBe(provider);
|
||||
expect(callArgs?.model).toBe(model);
|
||||
}
|
||||
|
||||
function readSessionStore<T>(storePath: string): Record<string, T> {
|
||||
return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<string, T>;
|
||||
}
|
||||
|
||||
async function withCrossAgentResumeFixture(
|
||||
run: (params: {
|
||||
home: string;
|
||||
storePattern: string;
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await withTempHome(async (home) => {
|
||||
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
||||
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
||||
const sessionId = "session-exec-hook";
|
||||
const sessionKey = "agent:exec:hook:gmail:thread-1";
|
||||
writeSessionStoreSeed(execStore, {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
},
|
||||
});
|
||||
mockConfig(home, storePattern, undefined, undefined, [
|
||||
{ id: "dev" },
|
||||
{ id: "exec", default: true },
|
||||
]);
|
||||
await agentCommand({ message: "resume me", sessionId }, runtime);
|
||||
await run({ home, storePattern, sessionId, sessionKey });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPersistedSessionFile(params: {
|
||||
seedKey: string;
|
||||
sessionId: string;
|
||||
expectedPathFragment: string;
|
||||
}) {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
writeSessionStoreSeed(store, {
|
||||
[params.seedKey]: {
|
||||
sessionId: params.sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
mockConfig(home, store);
|
||||
await agentCommand({ message: "hi", sessionKey: params.seedKey }, runtime);
|
||||
const saved = readSessionStore<{ sessionId?: string; sessionFile?: string }>(store);
|
||||
const entry = saved[params.seedKey];
|
||||
expect(entry?.sessionId).toBe(params.sessionId);
|
||||
expect(entry?.sessionFile).toContain(params.expectedPathFragment);
|
||||
expect(getLastEmbeddedCall()?.sessionFile).toBe(entry?.sessionFile);
|
||||
});
|
||||
}
|
||||
|
||||
async function runAgentWithSessionKey(sessionKey: string): Promise<void> {
|
||||
await agentCommand({ message: "hi", sessionKey }, runtime);
|
||||
}
|
||||
|
||||
async function expectDefaultThinkLevel(params: {
|
||||
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
|
||||
catalogEntry: Record<string, unknown>;
|
||||
expected: string;
|
||||
}) {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, params.agentOverrides);
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([params.catalogEntry as never]);
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
expect(getLastEmbeddedCall()?.thinkLevel).toBe(params.expected);
|
||||
});
|
||||
}
|
||||
|
||||
function createTelegramOutboundPlugin() {
|
||||
const sendWithTelegram = async (
|
||||
ctx: {
|
||||
deps?: {
|
||||
sendTelegram?: (
|
||||
to: string,
|
||||
text: string,
|
||||
opts: Record<string, unknown>,
|
||||
) => Promise<{
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
}>;
|
||||
};
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string | null;
|
||||
mediaUrl?: string;
|
||||
},
|
||||
mediaUrl?: string,
|
||||
) => {
|
||||
const sendTelegram = ctx.deps?.sendTelegram;
|
||||
if (!sendTelegram) {
|
||||
throw new Error("sendTelegram dependency missing");
|
||||
}
|
||||
const result = await sendTelegram(ctx.to, ctx.text, {
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
...(mediaUrl ? { mediaUrl } : {}),
|
||||
verbose: false,
|
||||
});
|
||||
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
||||
};
|
||||
|
||||
return createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async (ctx) => sendWithTelegram(ctx),
|
||||
sendMedia: async (ctx) => sendWithTelegram(ctx, ctx.mediaUrl),
|
||||
sendText: async (ctx) => {
|
||||
const sendTelegram = ctx.deps?.sendTelegram;
|
||||
if (!sendTelegram) {
|
||||
throw new Error("sendTelegram dependency missing");
|
||||
}
|
||||
const result = await sendTelegram(ctx.to, ctx.text, {
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
verbose: false,
|
||||
});
|
||||
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
||||
},
|
||||
sendMedia: async (ctx) => {
|
||||
const sendTelegram = ctx.deps?.sendTelegram;
|
||||
if (!sendTelegram) {
|
||||
throw new Error("sendTelegram dependency missing");
|
||||
}
|
||||
const result = await sendTelegram(ctx.to, ctx.text, {
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
mediaUrl: ctx.mediaUrl,
|
||||
verbose: false,
|
||||
});
|
||||
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never);
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult());
|
||||
runCliAgentSpy.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
|
||||
});
|
||||
@@ -300,20 +191,28 @@ describe("agentCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "defaults senderIsOwner to true for local agent runs",
|
||||
args: { message: "hi", to: "+1555" },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "honors explicit senderIsOwner override",
|
||||
args: { message: "hi", to: "+1555", senderIsOwner: false },
|
||||
expected: false,
|
||||
},
|
||||
])("$name", async ({ args, expected }) => {
|
||||
const callArgs = await runEmbeddedWithTempConfig({ args });
|
||||
expect(callArgs?.senderIsOwner).toBe(expected);
|
||||
it("defaults senderIsOwner to true for local agent runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.senderIsOwner).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("honors explicit senderIsOwner override", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555", senderIsOwner: false }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.senderIsOwner).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes when session-id is provided", async () => {
|
||||
@@ -336,21 +235,53 @@ describe("agentCommand", () => {
|
||||
});
|
||||
|
||||
it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => {
|
||||
await withCrossAgentResumeFixture(async ({ sessionKey }) => {
|
||||
const callArgs = getLastEmbeddedCall();
|
||||
expect(callArgs?.sessionKey).toBe(sessionKey);
|
||||
await withTempHome(async (home) => {
|
||||
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
||||
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
||||
writeSessionStoreSeed(execStore, {
|
||||
"agent:exec:hook:gmail:thread-1": {
|
||||
sessionId: "session-exec-hook",
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
},
|
||||
});
|
||||
mockConfig(home, storePattern, undefined, undefined, [
|
||||
{ id: "dev" },
|
||||
{ id: "exec", default: true },
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.sessionKey).toBe("agent:exec:hook:gmail:thread-1");
|
||||
expect(callArgs?.agentId).toBe("exec");
|
||||
expect(callArgs?.agentDir).toContain(`${path.sep}agents${path.sep}exec${path.sep}agent`);
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards resolved outbound session context when resuming by sessionId", async () => {
|
||||
await withCrossAgentResumeFixture(async ({ sessionKey }) => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
||||
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
||||
writeSessionStoreSeed(execStore, {
|
||||
"agent:exec:hook:gmail:thread-1": {
|
||||
sessionId: "session-exec-hook",
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
},
|
||||
});
|
||||
mockConfig(home, storePattern, undefined, undefined, [
|
||||
{ id: "dev" },
|
||||
{ id: "exec", default: true },
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
|
||||
|
||||
const deliverCall = deliverAgentCommandResultSpy.mock.calls.at(-1)?.[0];
|
||||
expect(deliverCall?.opts.sessionKey).toBeUndefined();
|
||||
expect(deliverCall?.outboundSession).toEqual(
|
||||
expect.objectContaining({
|
||||
key: sessionKey,
|
||||
key: "agent:exec:hook:gmail:thread-1",
|
||||
agentId: "exec",
|
||||
}),
|
||||
);
|
||||
@@ -431,7 +362,9 @@ describe("agentCommand", () => {
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
expectLastRunProviderModel("openai", "gpt-4.1-mini");
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.provider).toBe("openai");
|
||||
expect(callArgs?.model).toBe("gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,7 +446,13 @@ describe("agentCommand", () => {
|
||||
{ id: "claude-opus-4-5", name: "Opus", provider: "anthropic" },
|
||||
]);
|
||||
|
||||
await runAgentWithSessionKey("agent:main:subagent:allow-any");
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:subagent:allow-any",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.provider).toBe("openai");
|
||||
@@ -558,9 +497,17 @@ describe("agentCommand", () => {
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
||||
]);
|
||||
|
||||
await runAgentWithSessionKey("agent:main:subagent:clear-overrides");
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:subagent:clear-overrides",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expectLastRunProviderModel("openai", "gpt-4.1-mini");
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.provider).toBe("openai");
|
||||
expect(callArgs?.model).toBe("gpt-4.1-mini");
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
||||
string,
|
||||
@@ -619,18 +566,68 @@ describe("agentCommand", () => {
|
||||
});
|
||||
|
||||
it("persists resolved sessionFile for existing session keys", async () => {
|
||||
await expectPersistedSessionFile({
|
||||
seedKey: "agent:main:subagent:abc",
|
||||
sessionId: "sess-main",
|
||||
expectedPathFragment: `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
writeSessionStoreSeed(store, {
|
||||
"agent:main:subagent:abc": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:subagent:abc",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
||||
string,
|
||||
{ sessionId?: string; sessionFile?: string }
|
||||
>;
|
||||
const entry = saved["agent:main:subagent:abc"];
|
||||
expect(entry?.sessionId).toBe("sess-main");
|
||||
expect(entry?.sessionFile).toContain(
|
||||
`${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
|
||||
);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves topic transcript suffix when persisting missing sessionFile", async () => {
|
||||
await expectPersistedSessionFile({
|
||||
seedKey: "agent:main:telegram:group:123:topic:456",
|
||||
sessionId: "sess-topic",
|
||||
expectedPathFragment: "sess-topic-topic-456.jsonl",
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
writeSessionStoreSeed(store, {
|
||||
"agent:main:telegram:group:123:topic:456": {
|
||||
sessionId: "sess-topic",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
sessionKey: "agent:main:telegram:group:123:topic:456",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
||||
string,
|
||||
{ sessionId?: string; sessionFile?: string }
|
||||
>;
|
||||
const entry = saved["agent:main:telegram:group:123:topic:456"];
|
||||
expect(entry?.sessionId).toBe("sess-topic");
|
||||
expect(entry?.sessionFile).toContain("sess-topic-topic-456.jsonl");
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -718,61 +715,76 @@ describe("agentCommand", () => {
|
||||
});
|
||||
|
||||
it("defaults thinking to low for reasoning-capable models", async () => {
|
||||
await expectDefaultThinkLevel({
|
||||
catalogEntry: {
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
expected: "low",
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.thinkLevel).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults thinking to adaptive for Anthropic Claude 4.6 models", async () => {
|
||||
await expectDefaultThinkLevel({
|
||||
agentOverrides: {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, {
|
||||
model: { primary: "anthropic/claude-opus-4-6" },
|
||||
models: { "anthropic/claude-opus-4-6": {} },
|
||||
},
|
||||
catalogEntry: {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Opus 4.6",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
expected: "adaptive",
|
||||
});
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Opus 4.6",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.thinkLevel).toBe("adaptive");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers per-model thinking over global thinkingDefault", async () => {
|
||||
await expectDefaultThinkLevel({
|
||||
agentOverrides: {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, {
|
||||
thinkingDefault: "low",
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {
|
||||
params: { thinking: "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogEntry: {
|
||||
id: "claude-opus-4-5",
|
||||
name: "Opus 4.5",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
},
|
||||
expected: "high",
|
||||
});
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.thinkLevel).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
it("prints JSON payload when requested", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(
|
||||
createDefaultAgentResult({
|
||||
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
|
||||
meta: {
|
||||
durationMs: 42,
|
||||
}),
|
||||
);
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
@@ -790,10 +802,15 @@ describe("agentCommand", () => {
|
||||
});
|
||||
|
||||
it("passes the message through as the agent prompt", async () => {
|
||||
const callArgs = await runEmbeddedWithTempConfig({
|
||||
args: { message: "ping", to: "+1333" },
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand({ message: "ping", to: "+1333" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.prompt).toBe("ping");
|
||||
});
|
||||
expect(callArgs?.prompt).toBe("ping");
|
||||
});
|
||||
|
||||
it("passes through telegram accountId when delivering", async () => {
|
||||
@@ -844,31 +861,48 @@ describe("agentCommand", () => {
|
||||
});
|
||||
|
||||
it("uses reply channel as the message channel context", async () => {
|
||||
const callArgs = await runEmbeddedWithTempConfig({
|
||||
args: { message: "hi", agentId: "ops", replyChannel: "slack" },
|
||||
agentsList: [{ id: "ops" }],
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
|
||||
|
||||
await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.messageChannel).toBe("slack");
|
||||
});
|
||||
expect(callArgs?.messageChannel).toBe("slack");
|
||||
});
|
||||
|
||||
it("prefers runContext for embedded routing", async () => {
|
||||
const callArgs = await runEmbeddedWithTempConfig({
|
||||
args: {
|
||||
message: "hi",
|
||||
to: "+1555",
|
||||
channel: "whatsapp",
|
||||
runContext: { messageChannel: "slack", accountId: "acct-2" },
|
||||
},
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand(
|
||||
{
|
||||
message: "hi",
|
||||
to: "+1555",
|
||||
channel: "whatsapp",
|
||||
runContext: { messageChannel: "slack", accountId: "acct-2" },
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.messageChannel).toBe("slack");
|
||||
expect(callArgs?.agentAccountId).toBe("acct-2");
|
||||
});
|
||||
expect(callArgs?.messageChannel).toBe("slack");
|
||||
expect(callArgs?.agentAccountId).toBe("acct-2");
|
||||
});
|
||||
|
||||
it("forwards accountId to embedded runs", async () => {
|
||||
const callArgs = await runEmbeddedWithTempConfig({
|
||||
args: { message: "hi", to: "+1555", accountId: "kev" },
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555", accountId: "kev" }, runtime);
|
||||
|
||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||
expect(callArgs?.agentAccountId).toBe("kev");
|
||||
});
|
||||
expect(callArgs?.agentAccountId).toBe("kev");
|
||||
});
|
||||
|
||||
it("logs output when delivery is disabled", async () => {
|
||||
|
||||
@@ -53,39 +53,6 @@ describe("applyAuthChoiceMiniMax", () => {
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
}
|
||||
|
||||
async function runMiniMaxChoice(params: {
|
||||
authChoice: Parameters<typeof applyAuthChoiceMiniMax>[0]["authChoice"];
|
||||
opts?: Parameters<typeof applyAuthChoiceMiniMax>[0]["opts"];
|
||||
env?: { apiKey?: string; oauthToken?: string };
|
||||
prompter?: Parameters<typeof createMinimaxPrompter>[0];
|
||||
}) {
|
||||
const agentDir = await setupTempState();
|
||||
resetMiniMaxEnv();
|
||||
if (params.env?.apiKey !== undefined) {
|
||||
process.env.MINIMAX_API_KEY = params.env.apiKey;
|
||||
}
|
||||
if (params.env?.oauthToken !== undefined) {
|
||||
process.env.MINIMAX_OAUTH_TOKEN = params.env.oauthToken;
|
||||
}
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
authChoice: params.authChoice,
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({
|
||||
text,
|
||||
confirm,
|
||||
...params.prompter,
|
||||
}),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
...(params.opts ? { opts: params.opts } : {}),
|
||||
});
|
||||
|
||||
return { agentDir, result, text, confirm };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await lifecycle.cleanup();
|
||||
});
|
||||
@@ -125,8 +92,18 @@ describe("applyAuthChoiceMiniMax", () => {
|
||||
])(
|
||||
"$caseName",
|
||||
async ({ authChoice, tokenProvider, token, profileId, provider, expectedModel }) => {
|
||||
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||
const agentDir = await setupTempState();
|
||||
resetMiniMaxEnv();
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
authChoice,
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({ text, confirm }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
tokenProvider,
|
||||
token,
|
||||
@@ -149,57 +126,80 @@ describe("applyAuthChoiceMiniMax", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "uses env token for minimax-api-key-cn as plaintext by default",
|
||||
opts: undefined,
|
||||
expectKey: "mm-env-token",
|
||||
expectKeyRef: undefined,
|
||||
expectConfirmCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "uses env token for minimax-api-key-cn as keyRef in ref mode",
|
||||
opts: { secretInputMode: "ref" as const },
|
||||
expectKey: undefined,
|
||||
expectKeyRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MINIMAX_API_KEY",
|
||||
},
|
||||
expectConfirmCalls: 0,
|
||||
},
|
||||
])("$name", async ({ opts, expectKey, expectKeyRef, expectConfirmCalls }) => {
|
||||
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||
it("uses env token for minimax-api-key-cn as plaintext by default", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.MINIMAX_API_KEY = "mm-env-token";
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
authChoice: "minimax-api-key-cn",
|
||||
opts,
|
||||
env: { apiKey: "mm-env-token" },
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({ text, confirm }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
if (!opts) {
|
||||
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
|
||||
provider: "minimax-cn",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe(
|
||||
"minimax-cn/MiniMax-M2.5",
|
||||
);
|
||||
}
|
||||
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
|
||||
provider: "minimax-cn",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe(
|
||||
"minimax-cn/MiniMax-M2.5",
|
||||
);
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
expect(confirm).toHaveBeenCalledTimes(expectConfirmCalls);
|
||||
expect(confirm).toHaveBeenCalled();
|
||||
|
||||
const parsed = await readAuthProfiles(agentDir);
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe(expectKey);
|
||||
if (expectKeyRef) {
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual(expectKeyRef);
|
||||
} else {
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined();
|
||||
}
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token");
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses env token for minimax-api-key-cn as keyRef in ref mode", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.MINIMAX_API_KEY = "mm-env-token";
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
authChoice: "minimax-api-key-cn",
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({ text, confirm }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
secretInputMode: "ref",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = await readAuthProfiles(agentDir);
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MINIMAX_API_KEY",
|
||||
});
|
||||
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses minimax-api-lightning default model", async () => {
|
||||
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||
const agentDir = await setupTempState();
|
||||
resetMiniMaxEnv();
|
||||
|
||||
const text = vi.fn(async () => "should-not-be-used");
|
||||
const confirm = vi.fn(async () => true);
|
||||
|
||||
const result = await applyAuthChoiceMiniMax({
|
||||
authChoice: "minimax-api-lightning",
|
||||
config: {},
|
||||
prompter: createMinimaxPrompter({ text, confirm }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
setDefaultModel: true,
|
||||
opts: {
|
||||
tokenProvider: "minimax",
|
||||
token: "mm-lightning-token",
|
||||
|
||||
@@ -24,117 +24,163 @@ describe("volcengine/byteplus auth choice", () => {
|
||||
return env.agentDir;
|
||||
}
|
||||
|
||||
function createTestContext(defaultSelect: string, confirmResult = true, textValue = "unused") {
|
||||
return {
|
||||
prompter: createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => confirmResult),
|
||||
text: vi.fn(async () => textValue),
|
||||
},
|
||||
{ defaultSelect },
|
||||
),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
};
|
||||
}
|
||||
afterEach(async () => {
|
||||
await lifecycle.cleanup();
|
||||
});
|
||||
|
||||
type ProviderAuthCase = {
|
||||
provider: "volcengine" | "byteplus";
|
||||
authChoice: "volcengine-api-key" | "byteplus-api-key";
|
||||
envVar: "VOLCANO_ENGINE_API_KEY" | "BYTEPLUS_API_KEY";
|
||||
envValue: string;
|
||||
profileId: "volcengine:default" | "byteplus:default";
|
||||
applyAuthChoice: typeof applyAuthChoiceVolcengine | typeof applyAuthChoiceBytePlus;
|
||||
};
|
||||
|
||||
async function runProviderAuthChoice(
|
||||
testCase: ProviderAuthCase,
|
||||
options?: {
|
||||
defaultSelect?: string;
|
||||
confirmResult?: boolean;
|
||||
textValue?: string;
|
||||
secretInputMode?: "ref";
|
||||
},
|
||||
) {
|
||||
it("stores volcengine env key as plaintext by default", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env[testCase.envVar] = testCase.envValue;
|
||||
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
|
||||
|
||||
const { prompter, runtime } = createTestContext(
|
||||
options?.defaultSelect ?? "plaintext",
|
||||
options?.confirmResult ?? true,
|
||||
options?.textValue ?? "unused",
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "plaintext" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await testCase.applyAuthChoice({
|
||||
authChoice: testCase.authChoice,
|
||||
const result = await applyAuthChoiceVolcengine({
|
||||
authChoice: "volcengine-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
...(options?.secretInputMode ? { opts: { secretInputMode: options.secretInputMode } } : {}),
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.config.auth?.profiles?.["volcengine:default"]).toMatchObject({
|
||||
provider: "volcengine",
|
||||
mode: "api_key",
|
||||
});
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
|
||||
return { result, parsed };
|
||||
}
|
||||
|
||||
const providerAuthCases: ProviderAuthCase[] = [
|
||||
{
|
||||
provider: "volcengine",
|
||||
authChoice: "volcengine-api-key",
|
||||
envVar: "VOLCANO_ENGINE_API_KEY",
|
||||
envValue: "volc-env-key",
|
||||
profileId: "volcengine:default",
|
||||
applyAuthChoice: applyAuthChoiceVolcengine,
|
||||
},
|
||||
{
|
||||
provider: "byteplus",
|
||||
authChoice: "byteplus-api-key",
|
||||
envVar: "BYTEPLUS_API_KEY",
|
||||
envValue: "byte-env-key",
|
||||
profileId: "byteplus:default",
|
||||
applyAuthChoice: applyAuthChoiceBytePlus,
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(async () => {
|
||||
await lifecycle.cleanup();
|
||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-env-key");
|
||||
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(providerAuthCases)(
|
||||
"stores $provider env key as plaintext by default",
|
||||
async (testCase) => {
|
||||
const { result, parsed } = await runProviderAuthChoice(testCase);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.config.auth?.profiles?.[testCase.profileId]).toMatchObject({
|
||||
provider: testCase.provider,
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(parsed.profiles?.[testCase.profileId]?.key).toBe(testCase.envValue);
|
||||
expect(parsed.profiles?.[testCase.profileId]?.keyRef).toBeUndefined();
|
||||
},
|
||||
);
|
||||
it("stores volcengine env key as keyRef in ref mode", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
|
||||
|
||||
it.each(providerAuthCases)("stores $provider env key as keyRef in ref mode", async (testCase) => {
|
||||
const { result, parsed } = await runProviderAuthChoice(testCase, {
|
||||
defaultSelect: "ref",
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "ref" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceVolcengine({
|
||||
authChoice: "volcengine-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(parsed.profiles?.[testCase.profileId]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: testCase.envVar },
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["volcengine:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" },
|
||||
});
|
||||
expect(parsed.profiles?.[testCase.profileId]?.key).toBeUndefined();
|
||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores byteplus env key as plaintext by default", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.BYTEPLUS_API_KEY = "byte-env-key";
|
||||
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "plaintext" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceBytePlus({
|
||||
authChoice: "byteplus-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.config.auth?.profiles?.["byteplus:default"]).toMatchObject({
|
||||
provider: "byteplus",
|
||||
mode: "api_key",
|
||||
});
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["byteplus:default"]?.key).toBe("byte-env-key");
|
||||
expect(parsed.profiles?.["byteplus:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores byteplus env key as keyRef in ref mode", async () => {
|
||||
const agentDir = await setupTempState();
|
||||
process.env.BYTEPLUS_API_KEY = "byte-env-key";
|
||||
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => true),
|
||||
text: vi.fn(async () => "unused"),
|
||||
},
|
||||
{ defaultSelect: "ref" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceBytePlus({
|
||||
authChoice: "byteplus-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["byteplus:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "BYTEPLUS_API_KEY" },
|
||||
});
|
||||
expect(parsed.profiles?.["byteplus:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores explicit volcengine key when env is not used", async () => {
|
||||
const { result, parsed } = await runProviderAuthChoice(providerAuthCases[0], {
|
||||
defaultSelect: "",
|
||||
confirmResult: false,
|
||||
textValue: "volc-manual-key",
|
||||
const agentDir = await setupTempState();
|
||||
const prompter = createWizardPrompter(
|
||||
{
|
||||
confirm: vi.fn(async () => false),
|
||||
text: vi.fn(async () => "volc-manual-key"),
|
||||
},
|
||||
{ defaultSelect: "" },
|
||||
);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoiceVolcengine({
|
||||
authChoice: "volcengine-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(agentDir);
|
||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-manual-key");
|
||||
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -10,20 +10,6 @@ import type { ChannelChoice } from "./onboard-types.js";
|
||||
import { getChannelOnboardingAdapter } from "./onboarding/registry.js";
|
||||
import type { ChannelOnboardingAdapter } from "./onboarding/types.js";
|
||||
|
||||
type ChannelOnboardingAdapterPatch = Partial<
|
||||
Pick<
|
||||
ChannelOnboardingAdapter,
|
||||
"configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus"
|
||||
>
|
||||
>;
|
||||
|
||||
type PatchedOnboardingAdapterFields = {
|
||||
configure?: ChannelOnboardingAdapter["configure"];
|
||||
configureInteractive?: ChannelOnboardingAdapter["configureInteractive"];
|
||||
configureWhenConfigured?: ChannelOnboardingAdapter["configureWhenConfigured"];
|
||||
getStatus?: ChannelOnboardingAdapter["getStatus"];
|
||||
};
|
||||
|
||||
export function setDefaultChannelPluginRegistryForTests(): void {
|
||||
const channels = [
|
||||
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
|
||||
@@ -36,46 +22,23 @@ export function setDefaultChannelPluginRegistryForTests(): void {
|
||||
setActivePluginRegistry(createTestRegistry(channels));
|
||||
}
|
||||
|
||||
export function patchChannelOnboardingAdapter(
|
||||
export function patchChannelOnboardingAdapter<K extends keyof ChannelOnboardingAdapter>(
|
||||
channel: ChannelChoice,
|
||||
patch: ChannelOnboardingAdapterPatch,
|
||||
patch: Pick<ChannelOnboardingAdapter, K>,
|
||||
): () => void {
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
if (!adapter) {
|
||||
throw new Error(`missing onboarding adapter for ${channel}`);
|
||||
}
|
||||
|
||||
const previous: PatchedOnboardingAdapterFields = {};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) {
|
||||
previous.getStatus = adapter.getStatus;
|
||||
adapter.getStatus = patch.getStatus ?? adapter.getStatus;
|
||||
const keys = Object.keys(patch) as K[];
|
||||
const previous = {} as Pick<ChannelOnboardingAdapter, K>;
|
||||
for (const key of keys) {
|
||||
previous[key] = adapter[key];
|
||||
adapter[key] = patch[key];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
|
||||
previous.configure = adapter.configure;
|
||||
adapter.configure = patch.configure ?? adapter.configure;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "configureInteractive")) {
|
||||
previous.configureInteractive = adapter.configureInteractive;
|
||||
adapter.configureInteractive = patch.configureInteractive;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "configureWhenConfigured")) {
|
||||
previous.configureWhenConfigured = adapter.configureWhenConfigured;
|
||||
adapter.configureWhenConfigured = patch.configureWhenConfigured;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) {
|
||||
adapter.getStatus = previous.getStatus!;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
|
||||
adapter.configure = previous.configure!;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "configureInteractive")) {
|
||||
adapter.configureInteractive = previous.configureInteractive;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "configureWhenConfigured")) {
|
||||
adapter.configureWhenConfigured = previous.configureWhenConfigured;
|
||||
for (const key of keys) {
|
||||
adapter[key] = previous[key];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ import {
|
||||
const runtime = createTestRuntime();
|
||||
let clackPrompterModule: typeof import("../wizard/clack-prompter.js");
|
||||
|
||||
function formatChannelStatusJoined(channelAccounts: Record<string, unknown>) {
|
||||
return formatGatewayChannelsStatusLines({ channelAccounts }).join("\n");
|
||||
}
|
||||
|
||||
describe("channels command", () => {
|
||||
beforeAll(async () => {
|
||||
clackPrompterModule = await import("../wizard/clack-prompter.js");
|
||||
@@ -49,53 +45,23 @@ describe("channels command", () => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
function getWrittenConfig<T>(): T {
|
||||
it("adds a non-default telegram account", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
await channelsAddCommand(
|
||||
{ channel: "telegram", account: "alerts", token: "123:abc" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
return configMocks.writeConfigFile.mock.calls[0]?.[0] as T;
|
||||
}
|
||||
|
||||
async function runRemoveWithConfirm(
|
||||
args: Parameters<typeof channelsRemoveCommand>[0],
|
||||
): Promise<void> {
|
||||
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
||||
const promptSpy = vi
|
||||
.spyOn(clackPrompterModule, "createClackPrompter")
|
||||
.mockReturnValue(prompt as never);
|
||||
try {
|
||||
await channelsRemoveCommand(args, runtime, { hasFlags: true });
|
||||
} finally {
|
||||
promptSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
async function addTelegramAccount(account: string, token: string): Promise<void> {
|
||||
await channelsAddCommand({ channel: "telegram", account, token }, runtime, {
|
||||
hasFlags: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function addAlertsTelegramAccount(token: string): Promise<{
|
||||
channels?: {
|
||||
telegram?: {
|
||||
enabled?: boolean;
|
||||
accounts?: Record<string, { botToken?: string }>;
|
||||
};
|
||||
};
|
||||
}> {
|
||||
await addTelegramAccount("alerts", token);
|
||||
return getWrittenConfig<{
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
telegram?: {
|
||||
enabled?: boolean;
|
||||
accounts?: Record<string, { botToken?: string }>;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
}
|
||||
|
||||
it("adds a non-default telegram account", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
const next = await addAlertsTelegramAccount("123:abc");
|
||||
};
|
||||
expect(next.channels?.telegram?.enabled).toBe(true);
|
||||
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
|
||||
});
|
||||
@@ -117,9 +83,13 @@ describe("channels command", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await addTelegramAccount("alerts", "alerts-token");
|
||||
await channelsAddCommand(
|
||||
{ channel: "telegram", account: "alerts", token: "alerts-token" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = getWrittenConfig<{
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
telegram?: {
|
||||
botToken?: string;
|
||||
@@ -139,7 +109,7 @@ describe("channels command", () => {
|
||||
>;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
};
|
||||
expect(next.channels?.telegram?.accounts?.default).toEqual({
|
||||
botToken: "legacy-token",
|
||||
dmPolicy: "allowlist",
|
||||
@@ -167,7 +137,20 @@ describe("channels command", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const next = await addAlertsTelegramAccount("alerts-token");
|
||||
await channelsAddCommand(
|
||||
{ channel: "telegram", account: "alerts", token: "alerts-token" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
telegram?: {
|
||||
enabled?: boolean;
|
||||
accounts?: Record<string, { botToken?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(next.channels?.telegram?.enabled).toBe(true);
|
||||
expect(next.channels?.telegram?.accounts?.default).toEqual({});
|
||||
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
|
||||
@@ -186,11 +169,12 @@ describe("channels command", () => {
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = getWrittenConfig<{
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
slack?: { enabled?: boolean; botToken?: string; appToken?: string };
|
||||
};
|
||||
}>();
|
||||
};
|
||||
expect(next.channels?.slack?.enabled).toBe(true);
|
||||
expect(next.channels?.slack?.botToken).toBe("xoxb-1");
|
||||
expect(next.channels?.slack?.appToken).toBe("xapp-1");
|
||||
@@ -215,11 +199,12 @@ describe("channels command", () => {
|
||||
hasFlags: true,
|
||||
});
|
||||
|
||||
const next = getWrittenConfig<{
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
discord?: { accounts?: Record<string, { token?: string }> };
|
||||
};
|
||||
}>();
|
||||
};
|
||||
expect(next.channels?.discord?.accounts?.work).toBeUndefined();
|
||||
expect(next.channels?.discord?.accounts?.default?.token).toBe("d0");
|
||||
});
|
||||
@@ -232,11 +217,11 @@ describe("channels command", () => {
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = getWrittenConfig<{
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
whatsapp?: { accounts?: Record<string, { name?: string }> };
|
||||
};
|
||||
}>();
|
||||
};
|
||||
expect(next.channels?.whatsapp?.accounts?.family?.name).toBe("Family Phone");
|
||||
});
|
||||
|
||||
@@ -265,13 +250,13 @@ describe("channels command", () => {
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = getWrittenConfig<{
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
signal?: {
|
||||
accounts?: Record<string, { account?: string; name?: string }>;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
};
|
||||
expect(next.channels?.signal?.accounts?.lab?.account).toBe("+15555550123");
|
||||
expect(next.channels?.signal?.accounts?.lab?.name).toBe("Lab");
|
||||
expect(next.channels?.signal?.accounts?.default?.name).toBe("Primary");
|
||||
@@ -285,12 +270,20 @@ describe("channels command", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await runRemoveWithConfirm({ channel: "discord", account: "default" });
|
||||
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
||||
const promptSpy = vi
|
||||
.spyOn(clackPrompterModule, "createClackPrompter")
|
||||
.mockReturnValue(prompt as never);
|
||||
|
||||
const next = getWrittenConfig<{
|
||||
await channelsRemoveCommand({ channel: "discord", account: "default" }, runtime, {
|
||||
hasFlags: true,
|
||||
});
|
||||
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: { discord?: { enabled?: boolean } };
|
||||
}>();
|
||||
};
|
||||
expect(next.channels?.discord?.enabled).toBe(false);
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("includes external auth profiles in JSON output", async () => {
|
||||
@@ -355,14 +348,14 @@ describe("channels command", () => {
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
const next = getWrittenConfig<{
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
telegram?: {
|
||||
name?: string;
|
||||
accounts?: Record<string, { botToken?: string; name?: string }>;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
};
|
||||
expect(next.channels?.telegram?.name).toBeUndefined();
|
||||
expect(next.channels?.telegram?.accounts?.default?.name).toBe("Primary Bot");
|
||||
});
|
||||
@@ -384,14 +377,14 @@ describe("channels command", () => {
|
||||
hasFlags: true,
|
||||
});
|
||||
|
||||
const next = getWrittenConfig<{
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
channels?: {
|
||||
discord?: {
|
||||
name?: string;
|
||||
accounts?: Record<string, { name?: string; token?: string }>;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
};
|
||||
expect(next.channels?.discord?.name).toBeUndefined();
|
||||
expect(next.channels?.discord?.accounts?.default?.name).toBe("Primary Bot");
|
||||
expect(next.channels?.discord?.accounts?.work?.token).toBe("d1");
|
||||
@@ -412,9 +405,8 @@ describe("channels command", () => {
|
||||
expect(telegramIndex).toBeLessThan(whatsappIndex);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "surfaces Discord privileged intent issues in channels status output",
|
||||
it("surfaces Discord privileged intent issues in channels status output", () => {
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
channelAccounts: {
|
||||
discord: [
|
||||
{
|
||||
@@ -425,14 +417,14 @@ describe("channels command", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
patterns: [
|
||||
/Warnings:/,
|
||||
/Message Content Intent is disabled/i,
|
||||
/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "surfaces Discord permission audit issues in channels status output",
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i);
|
||||
expect(lines.join("\n")).toMatch(/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/);
|
||||
});
|
||||
|
||||
it("surfaces Discord permission audit issues in channels status output", () => {
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
channelAccounts: {
|
||||
discord: [
|
||||
{
|
||||
@@ -452,10 +444,14 @@ describe("channels command", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
patterns: [/Warnings:/, /permission audit/i, /Channel 111/i],
|
||||
},
|
||||
{
|
||||
name: "surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled",
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/permission audit/i);
|
||||
expect(lines.join("\n")).toMatch(/Channel 111/i);
|
||||
});
|
||||
|
||||
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
@@ -466,54 +462,54 @@ describe("channels command", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
patterns: [/Warnings:/, /Telegram Bot API privacy mode/i],
|
||||
},
|
||||
])("$name", ({ channelAccounts, patterns }) => {
|
||||
const joined = formatChannelStatusJoined(channelAccounts);
|
||||
for (const pattern of patterns) {
|
||||
expect(joined).toMatch(pattern);
|
||||
}
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
||||
});
|
||||
|
||||
it("includes Telegram bot username from probe data", () => {
|
||||
const joined = formatChannelStatusJoined({
|
||||
telegram: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
probe: { ok: true, bot: { username: "openclaw_bot" } },
|
||||
},
|
||||
],
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
probe: { ok: true, bot: { username: "openclaw_bot" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(joined).toMatch(/bot:@openclaw_bot/);
|
||||
expect(lines.join("\n")).toMatch(/bot:@openclaw_bot/);
|
||||
});
|
||||
|
||||
it("surfaces Telegram group membership audit issues in channels status output", () => {
|
||||
const joined = formatChannelStatusJoined({
|
||||
telegram: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
audit: {
|
||||
hasWildcardUnmentionedGroups: true,
|
||||
unresolvedGroups: 1,
|
||||
groups: [
|
||||
{
|
||||
chatId: "-1001",
|
||||
ok: false,
|
||||
status: "left",
|
||||
error: "not in group",
|
||||
},
|
||||
],
|
||||
const lines = formatGatewayChannelsStatusLines({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
audit: {
|
||||
hasWildcardUnmentionedGroups: true,
|
||||
unresolvedGroups: 1,
|
||||
groups: [
|
||||
{
|
||||
chatId: "-1001",
|
||||
ok: false,
|
||||
status: "left",
|
||||
error: "not in group",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(joined).toMatch(/Warnings:/);
|
||||
expect(joined).toMatch(/membership probing is not possible/i);
|
||||
expect(joined).toMatch(/Group -1001/i);
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
|
||||
expect(lines.join("\n")).toMatch(/Group -1001/i);
|
||||
});
|
||||
|
||||
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
|
||||
@@ -595,8 +591,16 @@ describe("channels command", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await runRemoveWithConfirm({ channel: "telegram", account: "default" });
|
||||
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
||||
const promptSpy = vi
|
||||
.spyOn(clackPrompterModule, "createClackPrompter")
|
||||
.mockReturnValue(prompt as never);
|
||||
|
||||
await channelsRemoveCommand({ channel: "telegram", account: "default" }, runtime, {
|
||||
hasFlags: true,
|
||||
});
|
||||
|
||||
expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled();
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,56 +51,35 @@ function makeRuntime(): RuntimeEnv {
|
||||
|
||||
const noopPrompter = {} as WizardPrompter;
|
||||
|
||||
function createKilocodeProvider() {
|
||||
return {
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createApplyAuthChoiceConfig(includeMinimaxProvider = false) {
|
||||
return {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
kilocode: createKilocodeProvider(),
|
||||
...(includeMinimaxProvider
|
||||
? {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false) {
|
||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
||||
mocks.applyAuthChoice.mockResolvedValue(createApplyAuthChoiceConfig(includeMinimaxProvider));
|
||||
mocks.promptModelAllowlist.mockResolvedValue({
|
||||
models: ["kilocode/anthropic/claude-opus-4.6"],
|
||||
});
|
||||
|
||||
return promptAuthConfig({}, makeRuntime(), noopPrompter);
|
||||
}
|
||||
|
||||
describe("promptAuthConfig", () => {
|
||||
it("keeps Kilo provider models while applying allowlist defaults", async () => {
|
||||
const result = await runPromptAuthConfigWithAllowlist();
|
||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
||||
mocks.applyAuthChoice.mockResolvedValue({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
kilocode: {
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.promptModelAllowlist.mockResolvedValue({
|
||||
models: ["kilocode/anthropic/claude-opus-4.6"],
|
||||
});
|
||||
|
||||
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
|
||||
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
|
||||
"anthropic/claude-opus-4.6",
|
||||
"minimax/minimax-m2.5:free",
|
||||
@@ -111,7 +90,38 @@ describe("promptAuthConfig", () => {
|
||||
});
|
||||
|
||||
it("does not mutate provider model catalogs when allowlist is set", async () => {
|
||||
const result = await runPromptAuthConfigWithAllowlist(true);
|
||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
||||
mocks.applyAuthChoice.mockResolvedValue({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
kilocode: {
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
||||
],
|
||||
},
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.promptModelAllowlist.mockResolvedValue({
|
||||
models: ["kilocode/anthropic/claude-opus-4.6"],
|
||||
});
|
||||
|
||||
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
|
||||
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
|
||||
"anthropic/claude-opus-4.6",
|
||||
"minimax/minimax-m2.5:free",
|
||||
|
||||
@@ -28,109 +28,67 @@ describe("onboard auth credentials secret refs", () => {
|
||||
await lifecycle.cleanup();
|
||||
});
|
||||
|
||||
type AuthProfileEntry = { key?: string; keyRef?: unknown; metadata?: unknown };
|
||||
|
||||
async function withAuthEnv(
|
||||
prefix: string,
|
||||
run: (env: Awaited<ReturnType<typeof setupAuthTestEnv>>) => Promise<void>,
|
||||
) {
|
||||
const env = await setupAuthTestEnv(prefix);
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
await run(env);
|
||||
}
|
||||
|
||||
async function readProfile(
|
||||
agentDir: string,
|
||||
profileId: string,
|
||||
): Promise<AuthProfileEntry | undefined> {
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, AuthProfileEntry>;
|
||||
}>(agentDir);
|
||||
return parsed.profiles?.[profileId];
|
||||
}
|
||||
|
||||
async function expectStoredAuthKey(params: {
|
||||
prefix: string;
|
||||
envVar?: string;
|
||||
envValue?: string;
|
||||
profileId: string;
|
||||
apply: (agentDir: string) => Promise<void>;
|
||||
expected: AuthProfileEntry;
|
||||
absent?: Array<keyof AuthProfileEntry>;
|
||||
}) {
|
||||
await withAuthEnv(params.prefix, async (env) => {
|
||||
if (params.envVar && params.envValue !== undefined) {
|
||||
process.env[params.envVar] = params.envValue;
|
||||
}
|
||||
await params.apply(env.agentDir);
|
||||
const profile = await readProfile(env.agentDir, params.profileId);
|
||||
expect(profile).toMatchObject(params.expected);
|
||||
for (const key of params.absent ?? []) {
|
||||
expect(profile?.[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps env-backed moonshot key as plaintext by default", async () => {
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-",
|
||||
envVar: "MOONSHOT_API_KEY",
|
||||
envValue: "sk-moonshot-env",
|
||||
profileId: "moonshot:default",
|
||||
apply: async () => {
|
||||
await setMoonshotApiKey("sk-moonshot-env");
|
||||
},
|
||||
expected: {
|
||||
key: "sk-moonshot-env",
|
||||
},
|
||||
absent: ["keyRef"],
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
|
||||
|
||||
await setMoonshotApiKey("sk-moonshot-env");
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
||||
key: "sk-moonshot-env",
|
||||
});
|
||||
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores env-backed moonshot key as keyRef when secret-input-mode=ref", async () => {
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-ref-",
|
||||
envVar: "MOONSHOT_API_KEY",
|
||||
envValue: "sk-moonshot-env",
|
||||
profileId: "moonshot:default",
|
||||
apply: async (agentDir) => {
|
||||
await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" });
|
||||
},
|
||||
expected: {
|
||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||
},
|
||||
absent: ["key"],
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-ref-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
|
||||
|
||||
await setMoonshotApiKey("sk-moonshot-env", env.agentDir, { secretInputMode: "ref" });
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||
});
|
||||
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores ${ENV} moonshot input as keyRef even when env value is unset", async () => {
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-inline-ref-",
|
||||
profileId: "moonshot:default",
|
||||
apply: async () => {
|
||||
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
|
||||
},
|
||||
expected: {
|
||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||
},
|
||||
absent: ["key"],
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-inline-ref-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
|
||||
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||
});
|
||||
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps plaintext moonshot key when no env ref applies", async () => {
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-plaintext-",
|
||||
envVar: "MOONSHOT_API_KEY",
|
||||
envValue: "sk-moonshot-other",
|
||||
profileId: "moonshot:default",
|
||||
apply: async () => {
|
||||
await setMoonshotApiKey("sk-moonshot-plaintext");
|
||||
},
|
||||
expected: {
|
||||
key: "sk-moonshot-plaintext",
|
||||
},
|
||||
absent: ["keyRef"],
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-plaintext-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-other";
|
||||
|
||||
await setMoonshotApiKey("sk-moonshot-plaintext");
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
||||
key: "sk-moonshot-plaintext",
|
||||
});
|
||||
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves cloudflare metadata when storing keyRef", async () => {
|
||||
@@ -153,35 +111,35 @@ describe("onboard auth credentials secret refs", () => {
|
||||
});
|
||||
|
||||
it("keeps env-backed openai key as plaintext by default", async () => {
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-openai-",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
envValue: "sk-openai-env",
|
||||
profileId: "openai:default",
|
||||
apply: async () => {
|
||||
await setOpenaiApiKey("sk-openai-env");
|
||||
},
|
||||
expected: {
|
||||
key: "sk-openai-env",
|
||||
},
|
||||
absent: ["keyRef"],
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.OPENAI_API_KEY = "sk-openai-env";
|
||||
|
||||
await setOpenaiApiKey("sk-openai-env");
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["openai:default"]).toMatchObject({
|
||||
key: "sk-openai-env",
|
||||
});
|
||||
expect(parsed.profiles?.["openai:default"]?.keyRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores env-backed openai key as keyRef in ref mode", async () => {
|
||||
await expectStoredAuthKey({
|
||||
prefix: "openclaw-onboard-auth-credentials-openai-ref-",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
envValue: "sk-openai-env",
|
||||
profileId: "openai:default",
|
||||
apply: async (agentDir) => {
|
||||
await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" });
|
||||
},
|
||||
expected: {
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
absent: ["key"],
|
||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-ref-");
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
process.env.OPENAI_API_KEY = "sk-openai-env";
|
||||
|
||||
await setOpenaiApiKey("sk-openai-env", env.agentDir, { secretInputMode: "ref" });
|
||||
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||
}>(env.agentDir);
|
||||
expect(parsed.profiles?.["openai:default"]).toMatchObject({
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
});
|
||||
expect(parsed.profiles?.["openai:default"]?.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => {
|
||||
|
||||
@@ -31,70 +31,6 @@ function createUnexpectedPromptGuards() {
|
||||
};
|
||||
}
|
||||
|
||||
type SetupChannelsOptions = Parameters<typeof setupChannels>[3];
|
||||
|
||||
function runSetupChannels(
|
||||
cfg: OpenClawConfig,
|
||||
prompter: WizardPrompter,
|
||||
options?: SetupChannelsOptions,
|
||||
) {
|
||||
return setupChannels(cfg, createExitThrowingRuntime(), prompter, {
|
||||
skipConfirm: true,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function createQuickstartTelegramSelect(options?: {
|
||||
configuredAction?: "skip";
|
||||
strictUnexpected?: boolean;
|
||||
}) {
|
||||
return vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
if (options?.configuredAction && message.includes("already configured")) {
|
||||
return options.configuredAction;
|
||||
}
|
||||
if (options?.strictUnexpected) {
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
}
|
||||
|
||||
function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) {
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
return {
|
||||
prompter: createPrompter({ select, multiselect, text }),
|
||||
multiselect,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken,
|
||||
...(typeof enabled === "boolean" ? { enabled } : {}),
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function patchTelegramAdapter(overrides: Parameters<typeof patchChannelOnboardingAdapter>[1]) {
|
||||
return patchChannelOnboardingAdapter("telegram", {
|
||||
...overrides,
|
||||
getStatus:
|
||||
overrides.getStatus ??
|
||||
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
default: {
|
||||
access: vi.fn(async () => {
|
||||
@@ -145,7 +81,10 @@ describe("setupChannels", () => {
|
||||
text: text as unknown as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
forceAllowFromChannels: ["whatsapp"],
|
||||
});
|
||||
@@ -177,7 +116,10 @@ describe("setupChannels", () => {
|
||||
text: text as unknown as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
|
||||
@@ -204,7 +146,11 @@ describe("setupChannels", () => {
|
||||
text,
|
||||
});
|
||||
|
||||
await runSetupChannels({} as OpenClawConfig, prompter);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
});
|
||||
|
||||
const sawPrimer = note.mock.calls.some(
|
||||
([message, title]) =>
|
||||
@@ -216,18 +162,41 @@ describe("setupChannels", () => {
|
||||
});
|
||||
|
||||
it("prompts for configured channel action and skips configuration when told to skip", async () => {
|
||||
const select = createQuickstartTelegramSelect({
|
||||
configuredAction: "skip",
|
||||
strictUnexpected: true,
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
if (message.includes("already configured")) {
|
||||
return "skip";
|
||||
}
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
});
|
||||
const { prompter, multiselect, text } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
|
||||
await runSetupChannels(createTelegramCfg("token"), prompter, {
|
||||
quickstartDefaults: true,
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Select channel (QuickStart)" }),
|
||||
);
|
||||
@@ -262,26 +231,58 @@ describe("setupChannels", () => {
|
||||
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
await runSetupChannels(createTelegramCfg("token", false), prompter);
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "token",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses configureInteractive skip without mutating selection/account state", async () => {
|
||||
const select = createQuickstartTelegramSelect();
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureInteractive = vi.fn(async () => "skip" as const);
|
||||
const restore = patchTelegramAdapter({
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
configureInteractive,
|
||||
});
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
@@ -299,7 +300,12 @@ describe("setupChannels", () => {
|
||||
});
|
||||
|
||||
it("applies configureInteractive result cfg/account updates", async () => {
|
||||
const select = createQuickstartTelegramSelect();
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
@@ -315,16 +321,27 @@ describe("setupChannels", () => {
|
||||
const configure = vi.fn(async () => {
|
||||
throw new Error("configure should not be called when configureInteractive is present");
|
||||
});
|
||||
const restore = patchTelegramAdapter({
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
configureInteractive,
|
||||
configure,
|
||||
});
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
@@ -341,7 +358,12 @@ describe("setupChannels", () => {
|
||||
});
|
||||
|
||||
it("uses configureWhenConfigured when channel is already configured", async () => {
|
||||
const select = createQuickstartTelegramSelect();
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
@@ -359,21 +381,43 @@ describe("setupChannels", () => {
|
||||
"configure should not be called when configureWhenConfigured handles updates",
|
||||
);
|
||||
});
|
||||
const restore = patchTelegramAdapter({
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
configureInteractive: undefined,
|
||||
configureWhenConfigured,
|
||||
configure,
|
||||
});
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
});
|
||||
const cfg = await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "old-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
|
||||
expect(configureWhenConfigured).toHaveBeenCalledWith(
|
||||
@@ -389,28 +433,55 @@ describe("setupChannels", () => {
|
||||
});
|
||||
|
||||
it("respects configureWhenConfigured skip without mutating selection or account state", async () => {
|
||||
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
});
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureWhenConfigured = vi.fn(async () => "skip" as const);
|
||||
const configure = vi.fn(async () => {
|
||||
throw new Error("configure should not run when configureWhenConfigured handles skip");
|
||||
});
|
||||
const restore = patchTelegramAdapter({
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
configureInteractive: undefined,
|
||||
configureWhenConfigured,
|
||||
configure,
|
||||
});
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
});
|
||||
const cfg = await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "old-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(configureWhenConfigured).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configured: true, label: expect.any(String) }),
|
||||
@@ -425,27 +496,54 @@ describe("setupChannels", () => {
|
||||
});
|
||||
|
||||
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {
|
||||
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
|
||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
});
|
||||
const selection = vi.fn();
|
||||
const onAccountId = vi.fn();
|
||||
const configureInteractive = vi.fn(async () => "skip" as const);
|
||||
const configureWhenConfigured = vi.fn(async () => {
|
||||
throw new Error("configureWhenConfigured should not run when configureInteractive exists");
|
||||
});
|
||||
const restore = patchTelegramAdapter({
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
getStatus: vi.fn(async ({ cfg }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
configureInteractive,
|
||||
configureWhenConfigured,
|
||||
});
|
||||
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const runtime = createExitThrowingRuntime();
|
||||
try {
|
||||
await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
});
|
||||
await setupChannels(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "old-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
{
|
||||
skipConfirm: true,
|
||||
quickstartDefaults: true,
|
||||
onSelection: selection,
|
||||
onAccountId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(configureInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configured: true, label: expect.any(String) }),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
applyCustomApiConfig,
|
||||
@@ -77,43 +76,6 @@ function expectOpenAiCompatResult(params: {
|
||||
expect(params.result.config.models?.providers?.custom?.api).toBe("openai-completions");
|
||||
}
|
||||
|
||||
function buildCustomProviderConfig(contextWindow?: number) {
|
||||
if (contextWindow === undefined) {
|
||||
return {} as OpenClawConfig;
|
||||
}
|
||||
return {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions" as const,
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "foo-large",
|
||||
name: "foo-large",
|
||||
contextWindow,
|
||||
maxTokens: contextWindow > CONTEXT_WINDOW_HARD_MIN_TOKENS ? 4096 : 1024,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function applyCustomModelConfigWithContextWindow(contextWindow?: number) {
|
||||
return applyCustomApiConfig({
|
||||
config: buildCustomProviderConfig(contextWindow),
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "custom",
|
||||
});
|
||||
}
|
||||
|
||||
describe("promptCustomApiConfig", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -365,28 +327,89 @@ describe("promptCustomApiConfig", () => {
|
||||
});
|
||||
|
||||
describe("applyCustomApiConfig", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "uses hard-min context window for newly added custom models",
|
||||
existingContextWindow: undefined,
|
||||
expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||
},
|
||||
{
|
||||
name: "upgrades existing custom model context window when below hard minimum",
|
||||
existingContextWindow: 4096,
|
||||
expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||
},
|
||||
{
|
||||
name: "preserves existing custom model context window when already above minimum",
|
||||
existingContextWindow: 131072,
|
||||
expectedContextWindow: 131072,
|
||||
},
|
||||
])("$name", ({ existingContextWindow, expectedContextWindow }) => {
|
||||
const result = applyCustomModelConfigWithContextWindow(existingContextWindow);
|
||||
it("uses hard-min context window for newly added custom models", () => {
|
||||
const result = applyCustomApiConfig({
|
||||
config: {},
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "custom",
|
||||
});
|
||||
|
||||
const model = result.config.models?.providers?.custom?.models?.find(
|
||||
(entry) => entry.id === "foo-large",
|
||||
);
|
||||
expect(model?.contextWindow).toBe(expectedContextWindow);
|
||||
expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS);
|
||||
});
|
||||
|
||||
it("upgrades existing custom model context window when below hard minimum", () => {
|
||||
const result = applyCustomApiConfig({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "foo-large",
|
||||
name: "foo-large",
|
||||
contextWindow: 4096,
|
||||
maxTokens: 1024,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "custom",
|
||||
});
|
||||
|
||||
const model = result.config.models?.providers?.custom?.models?.find(
|
||||
(entry) => entry.id === "foo-large",
|
||||
);
|
||||
expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS);
|
||||
});
|
||||
|
||||
it("preserves existing custom model context window when already above minimum", () => {
|
||||
const result = applyCustomApiConfig({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "foo-large",
|
||||
name: "foo-large",
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "custom",
|
||||
});
|
||||
|
||||
const model = result.config.models?.providers?.custom?.models?.find(
|
||||
(entry) => entry.id === "foo-large",
|
||||
);
|
||||
expect(model?.contextWindow).toBe(131072);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -27,18 +27,6 @@ function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
||||
return createWizardPrompter(overrides, { defaultSelect: "" });
|
||||
}
|
||||
|
||||
function createSelectPrompter(
|
||||
responses: Partial<Record<string, string>>,
|
||||
): WizardPrompter["select"] {
|
||||
return vi.fn(async (params) => {
|
||||
const value = responses[params.message];
|
||||
if (value !== undefined) {
|
||||
return value as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
}
|
||||
|
||||
describe("promptRemoteGatewayConfig", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
|
||||
|
||||
@@ -61,10 +49,17 @@ describe("promptRemoteGatewayConfig", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const select = createSelectPrompter({
|
||||
"Select gateway": "0",
|
||||
"Connection method": "direct",
|
||||
"Gateway auth": "token",
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Select gateway") {
|
||||
return "0" as never;
|
||||
}
|
||||
if (params.message === "Connection method") {
|
||||
return "direct" as never;
|
||||
}
|
||||
if (params.message === "Gateway auth") {
|
||||
return "token" as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
|
||||
const text: WizardPrompter["text"] = vi.fn(async (params) => {
|
||||
@@ -111,7 +106,12 @@ describe("promptRemoteGatewayConfig", () => {
|
||||
return "";
|
||||
}) as WizardPrompter["text"];
|
||||
|
||||
const select = createSelectPrompter({ "Gateway auth": "off" });
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Gateway auth") {
|
||||
return "off" as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const prompter = createPrompter({
|
||||
@@ -138,7 +138,12 @@ describe("promptRemoteGatewayConfig", () => {
|
||||
return "";
|
||||
}) as WizardPrompter["text"];
|
||||
|
||||
const select = createSelectPrompter({ "Gateway auth": "off" });
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Gateway auth") {
|
||||
return "off" as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const prompter = createPrompter({
|
||||
|
||||
@@ -85,66 +85,6 @@ async function withUnknownUsageStore(run: () => Promise<void>) {
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeLogs() {
|
||||
return runtimeLogMock.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
}
|
||||
|
||||
function getJoinedRuntimeLogs() {
|
||||
return getRuntimeLogs().join("\n");
|
||||
}
|
||||
|
||||
async function runStatusAndGetLogs(args: Parameters<typeof statusCommand>[0] = {}) {
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand(args, runtime as never);
|
||||
return getRuntimeLogs();
|
||||
}
|
||||
|
||||
async function runStatusAndGetJoinedLogs(args: Parameters<typeof statusCommand>[0] = {}) {
|
||||
await runStatusAndGetLogs(args);
|
||||
return getJoinedRuntimeLogs();
|
||||
}
|
||||
|
||||
type ProbeGatewayResult = {
|
||||
ok: boolean;
|
||||
url: string;
|
||||
connectLatencyMs: number | null;
|
||||
error: string | null;
|
||||
close: { code: number; reason: string } | null;
|
||||
health: unknown;
|
||||
status: unknown;
|
||||
presence: unknown;
|
||||
configSnapshot: unknown;
|
||||
};
|
||||
|
||||
function mockProbeGatewayResult(overrides: Partial<ProbeGatewayResult>) {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "timeout",
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>): Promise<T> {
|
||||
const prevValue = process.env[key];
|
||||
process.env[key] = value;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (prevValue === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prevValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadSessionStore: vi.fn().mockReturnValue({
|
||||
"+1000": createDefaultSessionStoreEntry(),
|
||||
@@ -427,68 +367,86 @@ describe("statusCommand", () => {
|
||||
|
||||
it("prints unknown usage in formatted output when totalTokens is missing", async () => {
|
||||
await withUnknownUsageStore(async () => {
|
||||
const logs = await runStatusAndGetLogs();
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
const logs = await runStatusAndGetLogs();
|
||||
for (const token of [
|
||||
"OpenClaw status",
|
||||
"Overview",
|
||||
"Security audit",
|
||||
"Summary:",
|
||||
"CRITICAL",
|
||||
"Dashboard",
|
||||
"macos 14.0 (arm64)",
|
||||
"Memory",
|
||||
"Channels",
|
||||
"WhatsApp",
|
||||
"bootstrap files",
|
||||
"Sessions",
|
||||
"+1000",
|
||||
"50%",
|
||||
"40% cached",
|
||||
"LaunchAgent",
|
||||
"FAQ:",
|
||||
"Troubleshooting:",
|
||||
"Next steps:",
|
||||
]) {
|
||||
expect(logs.some((line) => line.includes(token))).toBe(true);
|
||||
}
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(logs.some((l: string) => l.includes("OpenClaw status"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Overview"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Security audit"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Summary:"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("CRITICAL"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Dashboard"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("macos 14.0 (arm64)"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Memory"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Channels"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("WhatsApp"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("bootstrap files"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Sessions"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("+1000"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("50%"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("40% cached"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("LaunchAgent"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("FAQ:"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Troubleshooting:"))).toBe(true);
|
||||
expect(logs.some((l: string) => l.includes("Next steps:"))).toBe(true);
|
||||
expect(
|
||||
logs.some(
|
||||
(line) =>
|
||||
line.includes("openclaw status --all") ||
|
||||
line.includes("openclaw --profile isolated status --all"),
|
||||
(l: string) =>
|
||||
l.includes("openclaw status --all") ||
|
||||
l.includes("openclaw --profile isolated status --all") ||
|
||||
l.includes("openclaw status --all") ||
|
||||
l.includes("openclaw --profile isolated status --all"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows gateway auth when reachable", async () => {
|
||||
await withEnvVar("OPENCLAW_GATEWAY_TOKEN", "abcd1234", async () => {
|
||||
mockProbeGatewayResult({
|
||||
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "abcd1234";
|
||||
try {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: 123,
|
||||
error: null,
|
||||
close: null,
|
||||
health: {},
|
||||
status: {},
|
||||
presence: [],
|
||||
configSnapshot: null,
|
||||
});
|
||||
const logs = await runStatusAndGetLogs();
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(logs.some((l: string) => l.includes("auth token"))).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces channel runtime errors from the gateway", async () => {
|
||||
mockProbeGatewayResult({
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: {},
|
||||
status: {},
|
||||
presence: [],
|
||||
configSnapshot: null,
|
||||
});
|
||||
mocks.callGateway.mockResolvedValueOnce({
|
||||
channelAccounts: {
|
||||
@@ -513,58 +471,98 @@ describe("statusCommand", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const joined = await runStatusAndGetJoinedLogs();
|
||||
expect(joined).toMatch(/Signal/i);
|
||||
expect(joined).toMatch(/iMessage/i);
|
||||
expect(joined).toMatch(/gateway:/i);
|
||||
expect(joined).toMatch(/WARN/);
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(logs.join("\n")).toMatch(/Signal/i);
|
||||
expect(logs.join("\n")).toMatch(/iMessage/i);
|
||||
expect(logs.join("\n")).toMatch(/gateway:/i);
|
||||
expect(logs.join("\n")).toMatch(/WARN/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "prints requestId-aware recovery guidance when gateway pairing is required",
|
||||
it("prints requestId-aware recovery guidance when gateway pairing is required", async () => {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "connect failed: pairing required (requestId: req-123)",
|
||||
closeReason: "pairing required (requestId: req-123)",
|
||||
includes: ["devices approve req-123"],
|
||||
excludes: [],
|
||||
},
|
||||
{
|
||||
name: "prints fallback recovery guidance when pairing requestId is unavailable",
|
||||
error: "connect failed: pairing required",
|
||||
closeReason: "connect failed",
|
||||
includes: [],
|
||||
excludes: ["devices approve req-"],
|
||||
},
|
||||
{
|
||||
name: "does not render unsafe requestId content into approval command hints",
|
||||
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
|
||||
closeReason: "pairing required (requestId: req-123;rm -rf /)",
|
||||
includes: [],
|
||||
excludes: ["devices approve req-123;rm -rf /"],
|
||||
},
|
||||
])("$name", async ({ error, closeReason, includes, excludes }) => {
|
||||
mockProbeGatewayResult({
|
||||
error,
|
||||
close: { code: 1008, reason: closeReason },
|
||||
close: { code: 1008, reason: "pairing required (requestId: req-123)" },
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
const joined = await runStatusAndGetJoinedLogs();
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
const joined = logs.join("\n");
|
||||
expect(joined).toContain("Gateway pairing approval required.");
|
||||
expect(joined).toContain("devices approve req-123");
|
||||
expect(joined).toContain("devices approve --latest");
|
||||
expect(joined).toContain("devices list");
|
||||
for (const expected of includes) {
|
||||
expect(joined).toContain(expected);
|
||||
}
|
||||
for (const blocked of excludes) {
|
||||
expect(joined).not.toContain(blocked);
|
||||
}
|
||||
});
|
||||
|
||||
it("prints fallback recovery guidance when pairing requestId is unavailable", async () => {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "connect failed: pairing required",
|
||||
close: { code: 1008, reason: "connect failed" },
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
const joined = logs.join("\n");
|
||||
expect(joined).toContain("Gateway pairing approval required.");
|
||||
expect(joined).not.toContain("devices approve req-");
|
||||
expect(joined).toContain("devices approve --latest");
|
||||
expect(joined).toContain("devices list");
|
||||
});
|
||||
|
||||
it("does not render unsafe requestId content into approval command hints", async () => {
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
|
||||
close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" },
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
|
||||
expect(joined).toContain("Gateway pairing approval required.");
|
||||
expect(joined).not.toContain("devices approve req-123;rm -rf /");
|
||||
expect(joined).toContain("devices approve --latest");
|
||||
});
|
||||
|
||||
it("extracts requestId from close reason when error text omits it", async () => {
|
||||
mockProbeGatewayResult({
|
||||
mocks.probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "connect failed: pairing required",
|
||||
close: { code: 1008, reason: "pairing required (requestId: req-close-456)" },
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
const joined = await runStatusAndGetJoinedLogs();
|
||||
|
||||
runtimeLogMock.mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
|
||||
expect(joined).toContain("devices approve req-close-456");
|
||||
});
|
||||
|
||||
|
||||
@@ -220,8 +220,6 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Disables Chromium sandbox isolation flags for environments where sandboxing fails at runtime. Keep this off whenever possible because process isolation protections are reduced.",
|
||||
"browser.attachOnly":
|
||||
"Restricts browser mode to attach-only behavior without starting local browser processes. Use this when all browser sessions are externally managed by a remote CDP provider.",
|
||||
"browser.cdpPortRangeStart":
|
||||
"Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.",
|
||||
"browser.defaultProfile":
|
||||
"Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.",
|
||||
"browser.profiles":
|
||||
|
||||
@@ -105,7 +105,6 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"browser.headless": "Browser Headless Mode",
|
||||
"browser.noSandbox": "Browser No-Sandbox Mode",
|
||||
"browser.attachOnly": "Browser Attach-only Mode",
|
||||
"browser.cdpPortRangeStart": "Browser CDP Port Range Start",
|
||||
"browser.defaultProfile": "Browser Default Profile",
|
||||
"browser.profiles": "Browser Profiles",
|
||||
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
|
||||
|
||||
@@ -48,8 +48,6 @@ export type BrowserConfig = {
|
||||
noSandbox?: boolean;
|
||||
/** If true: never launch; only attach to an existing browser. Default: false */
|
||||
attachOnly?: boolean;
|
||||
/** Starting local CDP port for auto-assigned browser profiles. Default derives from gateway port. */
|
||||
cdpPortRangeStart?: number;
|
||||
/** Default profile to use when profile param is omitted. Default: "chrome" */
|
||||
defaultProfile?: string;
|
||||
/** Named browser profiles with explicit CDP ports or URLs. */
|
||||
|
||||
@@ -250,7 +250,6 @@ export const OpenClawSchema = z
|
||||
headless: z.boolean().optional(),
|
||||
noSandbox: z.boolean().optional(),
|
||||
attachOnly: z.boolean().optional(),
|
||||
cdpPortRangeStart: z.number().int().min(1).max(65535).optional(),
|
||||
defaultProfile: z.string().optional(),
|
||||
snapshotDefaults: BrowserSnapshotDefaultsSchema,
|
||||
ssrfPolicy: z
|
||||
|
||||
@@ -21,29 +21,3 @@ vi.mock("../agents/model-selection.js", async (importOriginal) => {
|
||||
vi.mock("../agents/subagent-announce.js", () => ({
|
||||
runSubagentAnnounceFlow: vi.fn(),
|
||||
}));
|
||||
|
||||
type LooseRecord = Record<string, unknown>;
|
||||
|
||||
export function makeIsolatedAgentJob(overrides?: LooseRecord) {
|
||||
return {
|
||||
id: "test-job",
|
||||
name: "Test Job",
|
||||
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
payload: { kind: "agentTurn", message: "test" },
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
export function makeIsolatedAgentParams(overrides?: LooseRecord) {
|
||||
const jobOverrides =
|
||||
overrides && "job" in overrides ? (overrides.job as LooseRecord | undefined) : undefined;
|
||||
return {
|
||||
cfg: {},
|
||||
deps: {} as never,
|
||||
job: makeIsolatedAgentJob(jobOverrides),
|
||||
message: "test",
|
||||
sessionKey: "cron:test",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,23 +28,6 @@ async function runExplicitTelegramAnnounceTurn(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function withTelegramAnnounceFixture(
|
||||
run: (params: { home: string; storePath: string; deps: CliDeps }) => Promise<void>,
|
||||
params?: {
|
||||
deps?: Partial<CliDeps>;
|
||||
sessionStore?: { lastProvider?: string; lastTo?: string };
|
||||
},
|
||||
): Promise<void> {
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, {
|
||||
lastProvider: params?.sessionStore?.lastProvider ?? "webchat",
|
||||
lastTo: params?.sessionStore?.lastTo ?? "",
|
||||
});
|
||||
const deps = createCliDeps(params?.deps);
|
||||
await run({ home, storePath, deps });
|
||||
});
|
||||
}
|
||||
|
||||
function expectDeliveredOk(result: Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>): void {
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.delivered).toBe(true);
|
||||
@@ -53,67 +36,12 @@ function expectDeliveredOk(result: Awaited<ReturnType<typeof runCronIsolatedAgen
|
||||
async function expectBestEffortTelegramNotDelivered(
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await expectStructuredTelegramFailure({
|
||||
payload,
|
||||
bestEffort: true,
|
||||
expectedStatus: "ok",
|
||||
expectDeliveryAttempted: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectStructuredTelegramFailure(params: {
|
||||
payload: Record<string, unknown>;
|
||||
bestEffort: boolean;
|
||||
expectedStatus: "ok" | "error";
|
||||
expectedErrorFragment?: string;
|
||||
expectDeliveryAttempted?: boolean;
|
||||
}): Promise<void> {
|
||||
await withTelegramAnnounceFixture(
|
||||
async ({ home, storePath, deps }) => {
|
||||
mockAgentPayloads([params.payload]);
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
delivery: {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
...(params.bestEffort ? { bestEffort: true } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(params.expectedStatus);
|
||||
if (params.expectedStatus === "ok") {
|
||||
expect(res.delivered).toBe(false);
|
||||
}
|
||||
if (params.expectDeliveryAttempted !== undefined) {
|
||||
expect(res.deliveryAttempted).toBe(params.expectDeliveryAttempted);
|
||||
}
|
||||
if (params.expectedErrorFragment) {
|
||||
expect(res.error).toContain(params.expectedErrorFragment);
|
||||
}
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{
|
||||
deps: {
|
||||
sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runAnnounceFlowResult(bestEffort: boolean) {
|
||||
let outcome:
|
||||
| {
|
||||
res: Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>;
|
||||
deps: CliDeps;
|
||||
}
|
||||
| undefined;
|
||||
await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
|
||||
mockAgentPayloads([{ text: "hello from cron" }]);
|
||||
vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false);
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps({
|
||||
sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
});
|
||||
mockAgentPayloads([payload]);
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
@@ -122,22 +50,25 @@ async function runAnnounceFlowResult(bestEffort: boolean) {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
bestEffort,
|
||||
bestEffort: true,
|
||||
},
|
||||
});
|
||||
outcome = { res, deps };
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(false);
|
||||
expect(res.deliveryAttempted).toBe(true);
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
if (!outcome) {
|
||||
throw new Error("announce flow did not produce an outcome");
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function expectExplicitTelegramTargetAnnounce(params: {
|
||||
payloads: Array<Record<string, unknown>>;
|
||||
expectedText: string;
|
||||
}): Promise<void> {
|
||||
await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads(params.payloads);
|
||||
const res = await runExplicitTelegramAnnounceTurn({
|
||||
home,
|
||||
@@ -185,7 +116,9 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
|
||||
it("routes announce injection to the delivery-target session key", async () => {
|
||||
await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads([{ text: "hello from cron" }]);
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
@@ -267,7 +200,9 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
|
||||
it("skips announce when messaging tool already sent to target", async () => {
|
||||
await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads([{ text: "sent" }], {
|
||||
didSendViaMessagingTool: true,
|
||||
messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }],
|
||||
@@ -293,7 +228,9 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
|
||||
it("skips announce for heartbeat-only output", async () => {
|
||||
await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads([{ text: "HEARTBEAT_OK" }]);
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
@@ -309,28 +246,76 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
|
||||
it("fails when structured direct delivery fails and best-effort is disabled", async () => {
|
||||
await expectStructuredTelegramFailure({
|
||||
payload: { text: "hello from cron", mediaUrl: "https://example.com/img.png" },
|
||||
bestEffort: false,
|
||||
expectedStatus: "error",
|
||||
expectedErrorFragment: "boom",
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps({
|
||||
sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
});
|
||||
mockAgentPayloads([{ text: "hello from cron", mediaUrl: "https://example.com/img.png" }]);
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe("error");
|
||||
expect(res.error).toContain("boom");
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when announce delivery reports false and best-effort is disabled", async () => {
|
||||
const { res, deps } = await runAnnounceFlowResult(false);
|
||||
expect(res.status).toBe("error");
|
||||
expect(res.error).toContain("cron announce delivery failed");
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads([{ text: "hello from cron" }]);
|
||||
vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false);
|
||||
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
delivery: {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
bestEffort: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe("error");
|
||||
expect(res.error).toContain("cron announce delivery failed");
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("marks attempted when announce delivery reports false and best-effort is enabled", async () => {
|
||||
const { res, deps } = await runAnnounceFlowResult(true);
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(false);
|
||||
expect(res.deliveryAttempted).toBe(true);
|
||||
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
const deps = createCliDeps();
|
||||
mockAgentPayloads([{ text: "hello from cron" }]);
|
||||
vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false);
|
||||
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
delivery: {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
bestEffort: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(false);
|
||||
expect(res.deliveryAttempted).toBe(true);
|
||||
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores structured direct delivery failures when best-effort is enabled", async () => {
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import "./isolated-agent.mocks.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
|
||||
}));
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-cron-submodel-" });
|
||||
}
|
||||
@@ -91,93 +100,19 @@ function mockEmbeddedAgent() {
|
||||
});
|
||||
}
|
||||
|
||||
async function runSubagentModelCase(params: {
|
||||
home: string;
|
||||
cfgOverrides?: Partial<OpenClawConfig>;
|
||||
jobModelOverride?: string;
|
||||
}) {
|
||||
const storePath = await writeSessionStore(params.home);
|
||||
mockEmbeddedAgent();
|
||||
const job = makeJob();
|
||||
if (params.jobModelOverride) {
|
||||
job.payload = { kind: "agentTurn", message: "do work", model: params.jobModelOverride };
|
||||
}
|
||||
|
||||
await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(params.home, storePath, params.cfgOverrides),
|
||||
deps: makeDeps(),
|
||||
job,
|
||||
message: "do work",
|
||||
sessionKey: "cron:job-sub",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
return vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn: subagent model resolution (#11461)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "uses agents.defaults.subagents.model when set",
|
||||
cfgOverrides: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
subagents: { model: "ollama/llama3.2:3b" },
|
||||
},
|
||||
},
|
||||
} satisfies Partial<OpenClawConfig>,
|
||||
expectedProvider: "ollama",
|
||||
expectedModel: "llama3.2:3b",
|
||||
},
|
||||
{
|
||||
name: "falls back to main model when subagents.model is unset",
|
||||
cfgOverrides: undefined,
|
||||
expectedProvider: "anthropic",
|
||||
expectedModel: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "supports subagents.model with {primary} object format",
|
||||
cfgOverrides: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
subagents: { model: { primary: "google/gemini-2.5-flash" } },
|
||||
},
|
||||
},
|
||||
} satisfies Partial<OpenClawConfig>,
|
||||
expectedProvider: "google",
|
||||
expectedModel: "gemini-2.5-flash",
|
||||
},
|
||||
])("$name", async ({ cfgOverrides, expectedProvider, expectedModel }) => {
|
||||
it("uses agents.defaults.subagents.model when set", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const resolvedCfg =
|
||||
cfgOverrides === undefined
|
||||
? undefined
|
||||
: ({
|
||||
agents: {
|
||||
defaults: {
|
||||
...cfgOverrides.agents?.defaults,
|
||||
workspace: path.join(home, "openclaw"),
|
||||
},
|
||||
},
|
||||
} satisfies Partial<OpenClawConfig>);
|
||||
const call = await runSubagentModelCase({ home, cfgOverrides: resolvedCfg });
|
||||
expect(call?.provider).toBe(expectedProvider);
|
||||
expect(call?.model).toBe(expectedModel);
|
||||
});
|
||||
});
|
||||
const storePath = await writeSessionStore(home);
|
||||
mockEmbeddedAgent();
|
||||
|
||||
it("explicit job model override takes precedence over subagents.model", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const call = await runSubagentModelCase({
|
||||
home,
|
||||
cfgOverrides: {
|
||||
await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
@@ -185,11 +120,96 @@ describe("runCronIsolatedAgentTurn: subagent model resolution (#11461)", () => {
|
||||
subagents: { model: "ollama/llama3.2:3b" },
|
||||
},
|
||||
},
|
||||
},
|
||||
jobModelOverride: "openai/gpt-4o",
|
||||
}),
|
||||
deps: makeDeps(),
|
||||
job: makeJob(),
|
||||
message: "do work",
|
||||
sessionKey: "cron:job-sub",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
||||
expect(call?.provider).toBe("ollama");
|
||||
expect(call?.model).toBe("llama3.2:3b");
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit job model override takes precedence over subagents.model", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
mockEmbeddedAgent();
|
||||
|
||||
const job = makeJob();
|
||||
job.payload = { kind: "agentTurn", message: "do work", model: "openai/gpt-4o" };
|
||||
|
||||
await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
workspace: path.join(home, "openclaw"),
|
||||
subagents: { model: "ollama/llama3.2:3b" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
deps: makeDeps(),
|
||||
job,
|
||||
message: "do work",
|
||||
sessionKey: "cron:job-sub",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
||||
expect(call?.provider).toBe("openai");
|
||||
expect(call?.model).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to main model when subagents.model is unset", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
mockEmbeddedAgent();
|
||||
|
||||
await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps: makeDeps(),
|
||||
job: makeJob(),
|
||||
message: "do work",
|
||||
sessionKey: "cron:job-sub",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
||||
expect(call?.provider).toBe("anthropic");
|
||||
expect(call?.model).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
it("supports subagents.model with {primary} object format", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
mockEmbeddedAgent();
|
||||
|
||||
await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
workspace: path.join(home, "openclaw"),
|
||||
subagents: { model: { primary: "google/gemini-2.5-flash" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
deps: makeDeps(),
|
||||
job: makeJob(),
|
||||
message: "do work",
|
||||
sessionKey: "cron:job-sub",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
||||
expect(call?.provider).toBe("google");
|
||||
expect(call?.model).toBe("gemini-2.5-flash");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearFastTestEnv,
|
||||
loadRunCronIsolatedAgentTurn,
|
||||
logWarnMock,
|
||||
makeCronSession,
|
||||
makeCronSessionEntry,
|
||||
resolveAgentConfigMock,
|
||||
resolveAllowedModelRefMock,
|
||||
resolveConfiguredModelRefMock,
|
||||
resolveCronSessionMock,
|
||||
resetRunCronIsolatedAgentTurnHarness,
|
||||
restoreFastTestEnv,
|
||||
runWithModelFallbackMock,
|
||||
updateSessionStoreMock,
|
||||
} from "./run.test-harness.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
|
||||
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
|
||||
// ---------- mocks ----------
|
||||
|
||||
const resolveAgentConfigMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: resolveAgentConfigMock,
|
||||
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
|
||||
resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined),
|
||||
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
|
||||
resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
|
||||
resolveAgentSkillsFilter: vi.fn().mockReturnValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/skills.js", () => ({
|
||||
buildWorkspaceSkillSnapshot: vi.fn().mockReturnValue({
|
||||
prompt: "<available_skills></available_skills>",
|
||||
resolvedSkills: [],
|
||||
version: 42,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/skills/refresh.js", () => ({
|
||||
getSkillsSnapshotVersion: vi.fn().mockReturnValue(42),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/workspace.js", () => ({
|
||||
ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }),
|
||||
}));
|
||||
|
||||
const resolveAllowedModelRefMock = vi.fn();
|
||||
const resolveConfiguredModelRefMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }),
|
||||
isCliProvider: vi.fn().mockReturnValue(false),
|
||||
resolveAllowedModelRef: resolveAllowedModelRefMock,
|
||||
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
|
||||
resolveHooksGmailModel: vi.fn().mockReturnValue(null),
|
||||
resolveThinkingDefault: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: vi.fn(),
|
||||
}));
|
||||
|
||||
const runWithModelFallbackMock = vi.mocked(runWithModelFallback);
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/context.js", () => ({
|
||||
lookupContextTokens: vi.fn().mockReturnValue(128000),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/date-time.js", () => ({
|
||||
formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"),
|
||||
resolveUserTimeFormat: vi.fn().mockReturnValue("24h"),
|
||||
resolveUserTimezone: vi.fn().mockReturnValue("UTC"),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/timeout.js", () => ({
|
||||
resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/usage.js", () => ({
|
||||
deriveSessionTotalTokens: vi.fn().mockReturnValue(30),
|
||||
hasNonzeroUsage: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/subagent-announce.js", () => ({
|
||||
runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/cli-runner.js", () => ({
|
||||
runCliAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/cli-session.js", () => ({
|
||||
getCliSessionId: vi.fn().mockReturnValue(undefined),
|
||||
setCliSessionId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/thinking.js", () => ({
|
||||
normalizeThinkLevel: vi.fn().mockReturnValue(undefined),
|
||||
normalizeVerboseLevel: vi.fn().mockReturnValue("off"),
|
||||
supportsXHighThinking: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/outbound-send-deps.js", () => ({
|
||||
createOutboundSendDeps: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const updateSessionStoreMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
|
||||
resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
|
||||
setSessionRuntimeModel: vi.fn(),
|
||||
updateSessionStore: updateSessionStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../routing/session-key.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../routing/session-key.js")>();
|
||||
return {
|
||||
...actual,
|
||||
buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
|
||||
normalizeAgentId: vi.fn((id: string) => id),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/agent-events.js", () => ({
|
||||
registerAgentRunContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/deliver.js", () => ({
|
||||
deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/skills-remote.js", () => ({
|
||||
getRemoteSkillEligibility: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const logWarnMock = vi.fn();
|
||||
vi.mock("../../logger.js", () => ({
|
||||
logWarn: logWarnMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../security/external-content.js", () => ({
|
||||
buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"),
|
||||
detectSuspiciousPatterns: vi.fn().mockReturnValue([]),
|
||||
getHookType: vi.fn().mockReturnValue("unknown"),
|
||||
isExternalHookSession: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../delivery.js", () => ({
|
||||
resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }),
|
||||
}));
|
||||
|
||||
vi.mock("./delivery-target.js", () => ({
|
||||
resolveDeliveryTarget: vi.fn().mockResolvedValue({
|
||||
channel: "discord",
|
||||
to: undefined,
|
||||
accountId: undefined,
|
||||
error: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./helpers.js", () => ({
|
||||
isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false),
|
||||
pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined),
|
||||
pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"),
|
||||
pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
|
||||
pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
|
||||
resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100),
|
||||
}));
|
||||
|
||||
const resolveCronSessionMock = vi.fn();
|
||||
vi.mock("./session.js", () => ({
|
||||
resolveCronSession: resolveCronSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/defaults.js", () => ({
|
||||
DEFAULT_CONTEXT_TOKENS: 128000,
|
||||
DEFAULT_MODEL: "gpt-4",
|
||||
DEFAULT_PROVIDER: "openai",
|
||||
}));
|
||||
|
||||
const { runCronIsolatedAgentTurn } = await import("./run.js");
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
@@ -47,7 +209,10 @@ function makeParams(overrides?: Record<string, unknown>) {
|
||||
|
||||
function makeFreshSessionEntry(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
...makeCronSessionEntry(),
|
||||
sessionId: "test-session-id",
|
||||
updatedAt: 0,
|
||||
systemSent: false,
|
||||
skillsSnapshot: undefined,
|
||||
// Crucially: no model or modelProvider — simulates a brand-new session
|
||||
model: undefined as string | undefined,
|
||||
modelProvider: undefined as string | undefined,
|
||||
@@ -81,11 +246,12 @@ describe("runCronIsolatedAgentTurn — cron model override (#21057)", () => {
|
||||
// Hold onto the cron session *object* — the code may reassign its
|
||||
// `sessionEntry` property (e.g. during skills snapshot refresh), so
|
||||
// checking a stale reference would give a false negative.
|
||||
let cronSession: ReturnType<typeof makeCronSession>;
|
||||
let cronSession: { sessionEntry: ReturnType<typeof makeFreshSessionEntry>; [k: string]: unknown };
|
||||
|
||||
beforeEach(() => {
|
||||
previousFastTestEnv = clearFastTestEnv();
|
||||
resetRunCronIsolatedAgentTurnHarness();
|
||||
vi.clearAllMocks();
|
||||
previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
|
||||
// Agent default model is Opus
|
||||
resolveConfiguredModelRefMock.mockReturnValue({
|
||||
@@ -101,14 +267,22 @@ describe("runCronIsolatedAgentTurn — cron model override (#21057)", () => {
|
||||
resolveAgentConfigMock.mockReturnValue(undefined);
|
||||
updateSessionStoreMock.mockResolvedValue(undefined);
|
||||
|
||||
cronSession = makeCronSession({
|
||||
cronSession = {
|
||||
storePath: "/tmp/store.json",
|
||||
store: {},
|
||||
sessionEntry: makeFreshSessionEntry(),
|
||||
});
|
||||
systemSent: false,
|
||||
isNewSession: true,
|
||||
};
|
||||
resolveCronSessionMock.mockReturnValue(cronSession);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFastTestEnv(previousFastTestEnv);
|
||||
if (previousFastTestEnv == null) {
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
return;
|
||||
}
|
||||
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
|
||||
});
|
||||
|
||||
it("persists cron payload model on session entry even when the run throws", async () => {
|
||||
|
||||
@@ -1,18 +1,193 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearFastTestEnv,
|
||||
loadRunCronIsolatedAgentTurn,
|
||||
makeCronSession,
|
||||
resolveAgentModelFallbacksOverrideMock,
|
||||
resolveCronSessionMock,
|
||||
resetRunCronIsolatedAgentTurnHarness,
|
||||
restoreFastTestEnv,
|
||||
runWithModelFallbackMock,
|
||||
} from "./run.test-harness.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
|
||||
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
|
||||
// ---------- mocks (same pattern as run.skill-filter.test.ts) ----------
|
||||
|
||||
function makePayloadJob(overrides?: Record<string, unknown>) {
|
||||
const resolveAgentModelFallbacksOverrideMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: vi.fn(),
|
||||
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
|
||||
resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock,
|
||||
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
|
||||
resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
|
||||
resolveAgentSkillsFilter: vi.fn().mockReturnValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/skills.js", () => ({
|
||||
buildWorkspaceSkillSnapshot: vi.fn().mockReturnValue({
|
||||
prompt: "<available_skills></available_skills>",
|
||||
resolvedSkills: [],
|
||||
version: 42,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/skills/refresh.js", () => ({
|
||||
getSkillsSnapshotVersion: vi.fn().mockReturnValue(42),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/workspace.js", () => ({
|
||||
ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }),
|
||||
isCliProvider: vi.fn().mockReturnValue(false),
|
||||
resolveAllowedModelRef: vi
|
||||
.fn()
|
||||
.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }),
|
||||
resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }),
|
||||
resolveHooksGmailModel: vi.fn().mockReturnValue(null),
|
||||
resolveThinkingDefault: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
}),
|
||||
}));
|
||||
|
||||
const runWithModelFallbackMock = vi.mocked(runWithModelFallback);
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/context.js", () => ({
|
||||
lookupContextTokens: vi.fn().mockReturnValue(128000),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/date-time.js", () => ({
|
||||
formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"),
|
||||
resolveUserTimeFormat: vi.fn().mockReturnValue("24h"),
|
||||
resolveUserTimezone: vi.fn().mockReturnValue("UTC"),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/timeout.js", () => ({
|
||||
resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/usage.js", () => ({
|
||||
deriveSessionTotalTokens: vi.fn().mockReturnValue(30),
|
||||
hasNonzeroUsage: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/subagent-announce.js", () => ({
|
||||
runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/cli-runner.js", () => ({
|
||||
runCliAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/cli-session.js", () => ({
|
||||
getCliSessionId: vi.fn().mockReturnValue(undefined),
|
||||
setCliSessionId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/thinking.js", () => ({
|
||||
normalizeThinkLevel: vi.fn().mockReturnValue(undefined),
|
||||
normalizeVerboseLevel: vi.fn().mockReturnValue("off"),
|
||||
supportsXHighThinking: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/outbound-send-deps.js", () => ({
|
||||
createOutboundSendDeps: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
|
||||
resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
|
||||
setSessionRuntimeModel: vi.fn(),
|
||||
updateSessionStore: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../routing/session-key.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../routing/session-key.js")>();
|
||||
return {
|
||||
...actual,
|
||||
buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
|
||||
normalizeAgentId: vi.fn((id: string) => id),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/agent-events.js", () => ({
|
||||
registerAgentRunContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/deliver.js", () => ({
|
||||
deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/skills-remote.js", () => ({
|
||||
getRemoteSkillEligibility: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../logger.js", () => ({
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../security/external-content.js", () => ({
|
||||
buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"),
|
||||
detectSuspiciousPatterns: vi.fn().mockReturnValue([]),
|
||||
getHookType: vi.fn().mockReturnValue("unknown"),
|
||||
isExternalHookSession: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../delivery.js", () => ({
|
||||
resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }),
|
||||
}));
|
||||
|
||||
vi.mock("./delivery-target.js", () => ({
|
||||
resolveDeliveryTarget: vi.fn().mockResolvedValue({
|
||||
channel: "discord",
|
||||
to: undefined,
|
||||
accountId: undefined,
|
||||
error: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./helpers.js", () => ({
|
||||
isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false),
|
||||
pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined),
|
||||
pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"),
|
||||
pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
|
||||
pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
|
||||
resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100),
|
||||
}));
|
||||
|
||||
const resolveCronSessionMock = vi.fn();
|
||||
vi.mock("./session.js", () => ({
|
||||
resolveCronSession: resolveCronSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/defaults.js", () => ({
|
||||
DEFAULT_CONTEXT_TOKENS: 128000,
|
||||
DEFAULT_MODEL: "gpt-4",
|
||||
DEFAULT_PROVIDER: "openai",
|
||||
}));
|
||||
|
||||
const { runCronIsolatedAgentTurn } = await import("./run.js");
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function makeJob(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: "test-job",
|
||||
name: "Test Job",
|
||||
@@ -23,11 +198,11 @@ function makePayloadJob(overrides?: Record<string, unknown>) {
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makePayloadParams(overrides?: Record<string, unknown>) {
|
||||
function makeParams(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
cfg: {},
|
||||
deps: {} as never,
|
||||
job: makePayloadJob(overrides?.job as Record<string, unknown> | undefined),
|
||||
job: makeJob(overrides?.job ? (overrides.job as Record<string, unknown>) : undefined),
|
||||
message: "test",
|
||||
sessionKey: "cron:test",
|
||||
...overrides,
|
||||
@@ -40,50 +215,80 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
|
||||
let previousFastTestEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousFastTestEnv = clearFastTestEnv();
|
||||
resetRunCronIsolatedAgentTurnHarness();
|
||||
resolveCronSessionMock.mockReturnValue(makeCronSession());
|
||||
vi.clearAllMocks();
|
||||
previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
resolveAgentModelFallbacksOverrideMock.mockReturnValue(undefined);
|
||||
resolveCronSessionMock.mockReturnValue({
|
||||
storePath: "/tmp/store.json",
|
||||
store: {},
|
||||
sessionEntry: {
|
||||
sessionId: "test-session-id",
|
||||
updatedAt: 0,
|
||||
systemSent: false,
|
||||
skillsSnapshot: undefined,
|
||||
},
|
||||
systemSent: false,
|
||||
isNewSession: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFastTestEnv(previousFastTestEnv);
|
||||
if (previousFastTestEnv == null) {
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
return;
|
||||
}
|
||||
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "passes payload.fallbacks as fallbacksOverride when defined",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "test",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
|
||||
},
|
||||
expectedFallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
|
||||
},
|
||||
{
|
||||
name: "falls back to agent-level fallbacks when payload.fallbacks is undefined",
|
||||
payload: { kind: "agentTurn", message: "test" },
|
||||
agentFallbacks: ["openai/gpt-4o"],
|
||||
expectedFallbacks: ["openai/gpt-4o"],
|
||||
},
|
||||
{
|
||||
name: "payload.fallbacks=[] disables fallbacks even when agent config has them",
|
||||
payload: { kind: "agentTurn", message: "test", fallbacks: [] },
|
||||
agentFallbacks: ["openai/gpt-4o"],
|
||||
expectedFallbacks: [],
|
||||
},
|
||||
])("$name", async ({ payload, agentFallbacks, expectedFallbacks }) => {
|
||||
if (agentFallbacks) {
|
||||
resolveAgentModelFallbacksOverrideMock.mockReturnValue(agentFallbacks);
|
||||
}
|
||||
|
||||
it("passes payload.fallbacks as fallbacksOverride when defined", async () => {
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makePayloadParams({
|
||||
job: makePayloadJob({ payload }),
|
||||
makeParams({
|
||||
job: makeJob({
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "test",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
|
||||
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(expectedFallbacks);
|
||||
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"openai/gpt-5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to agent-level fallbacks when payload.fallbacks is undefined", async () => {
|
||||
resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]);
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
job: makeJob({ payload: { kind: "agentTurn", message: "test" } }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
|
||||
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(["openai/gpt-4o"]);
|
||||
});
|
||||
|
||||
it("payload.fallbacks=[] disables fallbacks even when agent config has them", async () => {
|
||||
resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]);
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
job: makeJob({
|
||||
payload: { kind: "agentTurn", message: "test", fallbacks: [] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
|
||||
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,198 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorkspaceSkillSnapshotMock,
|
||||
clearFastTestEnv,
|
||||
getCliSessionIdMock,
|
||||
isCliProviderMock,
|
||||
loadRunCronIsolatedAgentTurn,
|
||||
logWarnMock,
|
||||
makeCronSession,
|
||||
resolveAgentConfigMock,
|
||||
resolveAgentSkillsFilterMock,
|
||||
resolveAllowedModelRefMock,
|
||||
resolveCronSessionMock,
|
||||
resetRunCronIsolatedAgentTurnHarness,
|
||||
restoreFastTestEnv,
|
||||
runCliAgentMock,
|
||||
runWithModelFallbackMock,
|
||||
} from "./run.test-harness.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
|
||||
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
|
||||
// ---------- mocks ----------
|
||||
|
||||
function makeSkillJob(overrides?: Record<string, unknown>) {
|
||||
const buildWorkspaceSkillSnapshotMock = vi.fn();
|
||||
const resolveAgentConfigMock = vi.fn();
|
||||
const resolveAgentSkillsFilterMock = vi.fn();
|
||||
const getModelRefStatusMock = vi.fn().mockReturnValue({ allowed: false });
|
||||
const isCliProviderMock = vi.fn().mockReturnValue(false);
|
||||
const resolveAllowedModelRefMock = vi.fn();
|
||||
const resolveConfiguredModelRefMock = vi.fn();
|
||||
const resolveHooksGmailModelMock = vi.fn();
|
||||
const resolveThinkingDefaultMock = vi.fn();
|
||||
const logWarnMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: resolveAgentConfigMock,
|
||||
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
|
||||
resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined),
|
||||
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
|
||||
resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
|
||||
resolveAgentSkillsFilter: resolveAgentSkillsFilterMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/skills.js", () => ({
|
||||
buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/skills/refresh.js", () => ({
|
||||
getSkillsSnapshotVersion: vi.fn().mockReturnValue(42),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/workspace.js", () => ({
|
||||
ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getModelRefStatus: getModelRefStatusMock,
|
||||
isCliProvider: isCliProviderMock,
|
||||
resolveAllowedModelRef: resolveAllowedModelRefMock,
|
||||
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
|
||||
resolveHooksGmailModel: resolveHooksGmailModelMock,
|
||||
resolveThinkingDefault: resolveThinkingDefaultMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
}),
|
||||
}));
|
||||
|
||||
const runWithModelFallbackMock = vi.mocked(runWithModelFallback);
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/context.js", () => ({
|
||||
lookupContextTokens: vi.fn().mockReturnValue(128000),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/date-time.js", () => ({
|
||||
formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"),
|
||||
resolveUserTimeFormat: vi.fn().mockReturnValue("24h"),
|
||||
resolveUserTimezone: vi.fn().mockReturnValue("UTC"),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/timeout.js", () => ({
|
||||
resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/usage.js", () => ({
|
||||
deriveSessionTotalTokens: vi.fn().mockReturnValue(30),
|
||||
hasNonzeroUsage: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/subagent-announce.js", () => ({
|
||||
runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const runCliAgentMock = vi.fn();
|
||||
vi.mock("../../agents/cli-runner.js", () => ({
|
||||
runCliAgent: runCliAgentMock,
|
||||
}));
|
||||
|
||||
const getCliSessionIdMock = vi.fn().mockReturnValue(undefined);
|
||||
vi.mock("../../agents/cli-session.js", () => ({
|
||||
getCliSessionId: getCliSessionIdMock,
|
||||
setCliSessionId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/thinking.js", () => ({
|
||||
normalizeThinkLevel: vi.fn().mockReturnValue(undefined),
|
||||
normalizeVerboseLevel: vi.fn().mockReturnValue("off"),
|
||||
supportsXHighThinking: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/outbound-send-deps.js", () => ({
|
||||
createOutboundSendDeps: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
|
||||
resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
|
||||
setSessionRuntimeModel: vi.fn(),
|
||||
updateSessionStore: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../routing/session-key.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../routing/session-key.js")>();
|
||||
return {
|
||||
...actual,
|
||||
buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
|
||||
normalizeAgentId: vi.fn((id: string) => id),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/agent-events.js", () => ({
|
||||
registerAgentRunContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/deliver.js", () => ({
|
||||
deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/skills-remote.js", () => ({
|
||||
getRemoteSkillEligibility: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../logger.js", () => ({
|
||||
logWarn: (...args: unknown[]) => logWarnMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../security/external-content.js", () => ({
|
||||
buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"),
|
||||
detectSuspiciousPatterns: vi.fn().mockReturnValue([]),
|
||||
getHookType: vi.fn().mockReturnValue("unknown"),
|
||||
isExternalHookSession: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../delivery.js", () => ({
|
||||
resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }),
|
||||
}));
|
||||
|
||||
vi.mock("./delivery-target.js", () => ({
|
||||
resolveDeliveryTarget: vi.fn().mockResolvedValue({
|
||||
channel: "discord",
|
||||
to: undefined,
|
||||
accountId: undefined,
|
||||
error: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./helpers.js", () => ({
|
||||
isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false),
|
||||
pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined),
|
||||
pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"),
|
||||
pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
|
||||
pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
|
||||
resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100),
|
||||
}));
|
||||
|
||||
const resolveCronSessionMock = vi.fn();
|
||||
vi.mock("./session.js", () => ({
|
||||
resolveCronSession: resolveCronSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/defaults.js", () => ({
|
||||
DEFAULT_CONTEXT_TOKENS: 128000,
|
||||
DEFAULT_MODEL: "gpt-4",
|
||||
DEFAULT_PROVIDER: "openai",
|
||||
}));
|
||||
|
||||
const { runCronIsolatedAgentTurn } = await import("./run.js");
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function makeJob(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: "test-job",
|
||||
name: "Test Job",
|
||||
@@ -30,11 +203,11 @@ function makeSkillJob(overrides?: Record<string, unknown>) {
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makeSkillParams(overrides?: Record<string, unknown>) {
|
||||
function makeParams(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
cfg: {},
|
||||
deps: {} as never,
|
||||
job: makeSkillJob(overrides?.job as Record<string, unknown> | undefined),
|
||||
job: makeJob(),
|
||||
message: "test",
|
||||
sessionKey: "cron:test",
|
||||
...overrides,
|
||||
@@ -46,45 +219,57 @@ function makeSkillParams(overrides?: Record<string, unknown>) {
|
||||
describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
let previousFastTestEnv: string | undefined;
|
||||
beforeEach(() => {
|
||||
previousFastTestEnv = clearFastTestEnv();
|
||||
resetRunCronIsolatedAgentTurnHarness();
|
||||
resolveCronSessionMock.mockReturnValue(makeCronSession());
|
||||
vi.clearAllMocks();
|
||||
previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
buildWorkspaceSkillSnapshotMock.mockReturnValue({
|
||||
prompt: "<available_skills></available_skills>",
|
||||
resolvedSkills: [],
|
||||
version: 42,
|
||||
});
|
||||
resolveAgentConfigMock.mockReturnValue(undefined);
|
||||
resolveAgentSkillsFilterMock.mockReturnValue(undefined);
|
||||
resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" });
|
||||
resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } });
|
||||
resolveHooksGmailModelMock.mockReturnValue(null);
|
||||
resolveThinkingDefaultMock.mockReturnValue(undefined);
|
||||
getModelRefStatusMock.mockReturnValue({ allowed: false });
|
||||
isCliProviderMock.mockReturnValue(false);
|
||||
logWarnMock.mockReset();
|
||||
// Fresh session object per test — prevents mutation leaking between tests
|
||||
resolveCronSessionMock.mockReturnValue({
|
||||
storePath: "/tmp/store.json",
|
||||
store: {},
|
||||
sessionEntry: {
|
||||
sessionId: "test-session-id",
|
||||
updatedAt: 0,
|
||||
systemSent: false,
|
||||
skillsSnapshot: undefined,
|
||||
},
|
||||
systemSent: false,
|
||||
isNewSession: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFastTestEnv(previousFastTestEnv);
|
||||
if (previousFastTestEnv == null) {
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
return;
|
||||
}
|
||||
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
|
||||
});
|
||||
|
||||
async function runSkillFilterCase(overrides?: Record<string, unknown>) {
|
||||
const result = await runCronIsolatedAgentTurn(makeSkillParams(overrides));
|
||||
expect(result.status).toBe("ok");
|
||||
return result;
|
||||
}
|
||||
|
||||
function expectDefaultModelCall(params: { primary: string; fallbacks: string[] }) {
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
|
||||
const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg;
|
||||
const model = callCfg?.agents?.defaults?.model as { primary?: string; fallbacks?: string[] };
|
||||
expect(model?.primary).toBe(params.primary);
|
||||
expect(model?.fallbacks).toEqual(params.fallbacks);
|
||||
}
|
||||
|
||||
function mockCliFallbackInvocation() {
|
||||
runWithModelFallbackMock.mockImplementationOnce(
|
||||
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
|
||||
const result = await params.run("claude-cli", "claude-opus-4-6");
|
||||
return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("passes agent-level skillFilter to buildWorkspaceSkillSnapshot", async () => {
|
||||
resolveAgentSkillsFilterMock.mockReturnValue(["meme-factory", "weather"]);
|
||||
|
||||
await runSkillFilterCase({
|
||||
cfg: { agents: { list: [{ id: "scout", skills: ["meme-factory", "weather"] }] } },
|
||||
agentId: "scout",
|
||||
});
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
cfg: { agents: { list: [{ id: "scout", skills: ["meme-factory", "weather"] }] } },
|
||||
agentId: "scout",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce();
|
||||
expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [
|
||||
"meme-factory",
|
||||
@@ -95,10 +280,14 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
it("omits skillFilter when agent has no skills config", async () => {
|
||||
resolveAgentSkillsFilterMock.mockReturnValue(undefined);
|
||||
|
||||
await runSkillFilterCase({
|
||||
cfg: { agents: { list: [{ id: "general" }] } },
|
||||
agentId: "general",
|
||||
});
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
cfg: { agents: { list: [{ id: "general" }] } },
|
||||
agentId: "general",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce();
|
||||
// When no skills config, skillFilter should be undefined (no filtering applied)
|
||||
expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1].skillFilter).toBeUndefined();
|
||||
@@ -107,10 +296,14 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
it("passes empty skillFilter when agent explicitly disables all skills", async () => {
|
||||
resolveAgentSkillsFilterMock.mockReturnValue([]);
|
||||
|
||||
await runSkillFilterCase({
|
||||
cfg: { agents: { list: [{ id: "silent", skills: [] }] } },
|
||||
agentId: "silent",
|
||||
});
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
cfg: { agents: { list: [{ id: "silent", skills: [] }] } },
|
||||
agentId: "silent",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce();
|
||||
// Explicit empty skills list should forward [] to filter out all skills
|
||||
expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", []);
|
||||
@@ -135,10 +328,14 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
isNewSession: true,
|
||||
});
|
||||
|
||||
await runSkillFilterCase({
|
||||
cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather"] }] } },
|
||||
agentId: "weather-bot",
|
||||
});
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather"] }] } },
|
||||
agentId: "weather-bot",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce();
|
||||
expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [
|
||||
"weather",
|
||||
@@ -146,7 +343,9 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
});
|
||||
|
||||
it("forces a fresh session for isolated cron runs", async () => {
|
||||
await runSkillFilterCase();
|
||||
const result = await runCronIsolatedAgentTurn(makeParams());
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(resolveCronSessionMock).toHaveBeenCalledOnce();
|
||||
expect(resolveCronSessionMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
forceNew: true,
|
||||
@@ -173,10 +372,14 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
isNewSession: true,
|
||||
});
|
||||
|
||||
await runSkillFilterCase({
|
||||
cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather", "meme-factory"] }] } },
|
||||
agentId: "weather-bot",
|
||||
});
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather", "meme-factory"] }] } },
|
||||
agentId: "weather-bot",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(buildWorkspaceSkillSnapshotMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -189,21 +392,27 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
|
||||
async function expectPrimaryOverridePreservesDefaults(modelOverride: unknown) {
|
||||
resolveAgentConfigMock.mockReturnValue({ model: modelOverride });
|
||||
await runSkillFilterCase({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "scout",
|
||||
});
|
||||
agentId: "scout",
|
||||
}),
|
||||
);
|
||||
|
||||
expectDefaultModelCall({
|
||||
primary: "anthropic/claude-sonnet-4-5",
|
||||
fallbacks: defaultFallbacks,
|
||||
});
|
||||
expect(result.status).toBe("ok");
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
|
||||
const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg;
|
||||
const model = callCfg?.agents?.defaults?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
expect(model?.primary).toBe("anthropic/claude-sonnet-4-5");
|
||||
expect(model?.fallbacks).toEqual(defaultFallbacks);
|
||||
}
|
||||
|
||||
it("preserves defaults when agent overrides primary as string", async () => {
|
||||
@@ -220,8 +429,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
});
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeSkillParams({
|
||||
job: makeSkillJob({
|
||||
makeParams({
|
||||
job: makeJob({
|
||||
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
|
||||
}),
|
||||
}),
|
||||
@@ -240,25 +449,32 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
error: "model not allowed: anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
await runSkillFilterCase({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
job: makeSkillJob({
|
||||
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
|
||||
job: makeJob({
|
||||
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(logWarnMock).toHaveBeenCalledWith(
|
||||
"cron: payload.model 'anthropic/claude-sonnet-4-6' not allowed, falling back to agent defaults",
|
||||
);
|
||||
expectDefaultModelCall({
|
||||
primary: "openai-codex/gpt-5.3-codex",
|
||||
fallbacks: defaultFallbacks,
|
||||
});
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
|
||||
const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg;
|
||||
const model = callCfg?.agents?.defaults?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
expect(model?.primary).toBe("openai-codex/gpt-5.3-codex");
|
||||
expect(model?.fallbacks).toEqual(defaultFallbacks);
|
||||
});
|
||||
|
||||
it("returns an error when payload.model is invalid", async () => {
|
||||
@@ -267,8 +483,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
});
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(
|
||||
makeSkillParams({
|
||||
job: makeSkillJob({
|
||||
makeParams({
|
||||
job: makeJob({
|
||||
payload: { kind: "agentTurn", message: "test", model: "openai/" },
|
||||
}),
|
||||
}),
|
||||
@@ -291,7 +507,12 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
meta: { agentMeta: { sessionId: "new-cli-session-xyz", usage: { input: 5, output: 10 } } },
|
||||
});
|
||||
// Make runWithModelFallback invoke the run callback so the CLI path executes.
|
||||
mockCliFallbackInvocation();
|
||||
runWithModelFallbackMock.mockImplementationOnce(
|
||||
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
|
||||
const result = await params.run("claude-cli", "claude-opus-4-6");
|
||||
return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] };
|
||||
},
|
||||
);
|
||||
resolveCronSessionMock.mockReturnValue({
|
||||
storePath: "/tmp/store.json",
|
||||
store: {},
|
||||
@@ -307,7 +528,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
isNewSession: true,
|
||||
});
|
||||
|
||||
await runCronIsolatedAgentTurn(makeSkillParams());
|
||||
await runCronIsolatedAgentTurn(makeParams());
|
||||
|
||||
expect(runCliAgentMock).toHaveBeenCalledOnce();
|
||||
// Fresh session: cliSessionId must be undefined, not the stored value.
|
||||
@@ -323,7 +544,12 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
agentMeta: { sessionId: "existing-cli-session-def", usage: { input: 5, output: 10 } },
|
||||
},
|
||||
});
|
||||
mockCliFallbackInvocation();
|
||||
runWithModelFallbackMock.mockImplementationOnce(
|
||||
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
|
||||
const result = await params.run("claude-cli", "claude-opus-4-6");
|
||||
return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] };
|
||||
},
|
||||
);
|
||||
resolveCronSessionMock.mockReturnValue({
|
||||
storePath: "/tmp/store.json",
|
||||
store: {},
|
||||
@@ -338,7 +564,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
await runCronIsolatedAgentTurn(makeSkillParams());
|
||||
await runCronIsolatedAgentTurn(makeParams());
|
||||
|
||||
expect(runCliAgentMock).toHaveBeenCalledOnce();
|
||||
// Continuation: cliSessionId should be passed through for session resume.
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import { vi, type Mock } from "vitest";
|
||||
|
||||
type CronSessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
systemSent: boolean;
|
||||
skillsSnapshot: unknown;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type CronSession = {
|
||||
storePath: string;
|
||||
store: Record<string, unknown>;
|
||||
sessionEntry: CronSessionEntry;
|
||||
systemSent: boolean;
|
||||
isNewSession: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function createMock(): Mock {
|
||||
return vi.fn();
|
||||
}
|
||||
|
||||
export const buildWorkspaceSkillSnapshotMock = createMock();
|
||||
export const resolveAgentConfigMock = createMock();
|
||||
export const resolveAgentModelFallbacksOverrideMock = createMock();
|
||||
export const resolveAgentSkillsFilterMock = createMock();
|
||||
export const getModelRefStatusMock = createMock();
|
||||
export const isCliProviderMock = createMock();
|
||||
export const resolveAllowedModelRefMock = createMock();
|
||||
export const resolveConfiguredModelRefMock = createMock();
|
||||
export const resolveHooksGmailModelMock = createMock();
|
||||
export const resolveThinkingDefaultMock = createMock();
|
||||
export const runWithModelFallbackMock = createMock();
|
||||
export const runEmbeddedPiAgentMock = createMock();
|
||||
export const runCliAgentMock = createMock();
|
||||
export const getCliSessionIdMock = createMock();
|
||||
export const updateSessionStoreMock = createMock();
|
||||
export const resolveCronSessionMock = createMock();
|
||||
export const logWarnMock = createMock();
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: resolveAgentConfigMock,
|
||||
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
|
||||
resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock,
|
||||
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
|
||||
resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
|
||||
resolveAgentSkillsFilter: resolveAgentSkillsFilterMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/skills.js", () => ({
|
||||
buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/skills/refresh.js", () => ({
|
||||
getSkillsSnapshotVersion: vi.fn().mockReturnValue(42),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/workspace.js", () => ({
|
||||
ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getModelRefStatus: getModelRefStatusMock,
|
||||
isCliProvider: isCliProviderMock,
|
||||
resolveAllowedModelRef: resolveAllowedModelRefMock,
|
||||
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
|
||||
resolveHooksGmailModel: resolveHooksGmailModelMock,
|
||||
resolveThinkingDefault: resolveThinkingDefaultMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: runWithModelFallbackMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
runEmbeddedPiAgent: runEmbeddedPiAgentMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/context.js", () => ({
|
||||
lookupContextTokens: vi.fn().mockReturnValue(128000),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/date-time.js", () => ({
|
||||
formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"),
|
||||
resolveUserTimeFormat: vi.fn().mockReturnValue("24h"),
|
||||
resolveUserTimezone: vi.fn().mockReturnValue("UTC"),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/timeout.js", () => ({
|
||||
resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/usage.js", () => ({
|
||||
deriveSessionTotalTokens: vi.fn().mockReturnValue(30),
|
||||
hasNonzeroUsage: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/subagent-announce.js", () => ({
|
||||
runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/cli-runner.js", () => ({
|
||||
runCliAgent: runCliAgentMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/cli-session.js", () => ({
|
||||
getCliSessionId: getCliSessionIdMock,
|
||||
setCliSessionId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/thinking.js", () => ({
|
||||
normalizeThinkLevel: vi.fn().mockReturnValue(undefined),
|
||||
normalizeVerboseLevel: vi.fn().mockReturnValue("off"),
|
||||
supportsXHighThinking: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/outbound-send-deps.js", () => ({
|
||||
createOutboundSendDeps: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
|
||||
resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
|
||||
setSessionRuntimeModel: vi.fn(),
|
||||
updateSessionStore: updateSessionStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../routing/session-key.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../routing/session-key.js")>();
|
||||
return {
|
||||
...actual,
|
||||
buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
|
||||
normalizeAgentId: vi.fn((id: string) => id),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/agent-events.js", () => ({
|
||||
registerAgentRunContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/deliver.js", () => ({
|
||||
deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/skills-remote.js", () => ({
|
||||
getRemoteSkillEligibility: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../logger.js", () => ({
|
||||
logWarn: (...args: unknown[]) => logWarnMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../security/external-content.js", () => ({
|
||||
buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"),
|
||||
detectSuspiciousPatterns: vi.fn().mockReturnValue([]),
|
||||
getHookType: vi.fn().mockReturnValue("unknown"),
|
||||
isExternalHookSession: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("../delivery.js", () => ({
|
||||
resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }),
|
||||
}));
|
||||
|
||||
vi.mock("./delivery-target.js", () => ({
|
||||
resolveDeliveryTarget: vi.fn().mockResolvedValue({
|
||||
channel: "discord",
|
||||
to: undefined,
|
||||
accountId: undefined,
|
||||
error: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./helpers.js", () => ({
|
||||
isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false),
|
||||
pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined),
|
||||
pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"),
|
||||
pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
|
||||
pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
|
||||
resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100),
|
||||
}));
|
||||
|
||||
vi.mock("./session.js", () => ({
|
||||
resolveCronSession: resolveCronSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/defaults.js", () => ({
|
||||
DEFAULT_CONTEXT_TOKENS: 128000,
|
||||
DEFAULT_MODEL: "gpt-4",
|
||||
DEFAULT_PROVIDER: "openai",
|
||||
}));
|
||||
|
||||
export function makeCronSessionEntry(overrides?: Record<string, unknown>): CronSessionEntry {
|
||||
return {
|
||||
sessionId: "test-session-id",
|
||||
updatedAt: 0,
|
||||
systemSent: false,
|
||||
skillsSnapshot: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeCronSession(overrides?: Record<string, unknown>): CronSession {
|
||||
return {
|
||||
storePath: "/tmp/store.json",
|
||||
store: {},
|
||||
sessionEntry: makeCronSessionEntry(),
|
||||
systemSent: false,
|
||||
isNewSession: true,
|
||||
...overrides,
|
||||
} as CronSession;
|
||||
}
|
||||
|
||||
function makeDefaultModelFallbackResult() {
|
||||
return {
|
||||
result: {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
},
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDefaultEmbeddedResult() {
|
||||
return {
|
||||
payloads: [{ text: "test output" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
};
|
||||
}
|
||||
|
||||
export function resetRunCronIsolatedAgentTurnHarness(): void {
|
||||
vi.clearAllMocks();
|
||||
|
||||
buildWorkspaceSkillSnapshotMock.mockReturnValue({
|
||||
prompt: "<available_skills></available_skills>",
|
||||
resolvedSkills: [],
|
||||
version: 42,
|
||||
});
|
||||
resolveAgentConfigMock.mockReturnValue(undefined);
|
||||
resolveAgentModelFallbacksOverrideMock.mockReturnValue(undefined);
|
||||
resolveAgentSkillsFilterMock.mockReturnValue(undefined);
|
||||
|
||||
resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" });
|
||||
resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } });
|
||||
resolveHooksGmailModelMock.mockReturnValue(null);
|
||||
resolveThinkingDefaultMock.mockReturnValue(undefined);
|
||||
getModelRefStatusMock.mockReturnValue({ allowed: false });
|
||||
isCliProviderMock.mockReturnValue(false);
|
||||
|
||||
runWithModelFallbackMock.mockReset();
|
||||
runWithModelFallbackMock.mockResolvedValue(makeDefaultModelFallbackResult());
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
runEmbeddedPiAgentMock.mockResolvedValue(makeDefaultEmbeddedResult());
|
||||
|
||||
runCliAgentMock.mockReset();
|
||||
getCliSessionIdMock.mockReturnValue(undefined);
|
||||
|
||||
updateSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockResolvedValue(undefined);
|
||||
|
||||
resolveCronSessionMock.mockReset();
|
||||
resolveCronSessionMock.mockReturnValue(makeCronSession());
|
||||
|
||||
logWarnMock.mockReset();
|
||||
}
|
||||
|
||||
export function clearFastTestEnv(): string | undefined {
|
||||
const previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
return previousFastTestEnv;
|
||||
}
|
||||
|
||||
export function restoreFastTestEnv(previousFastTestEnv: string | undefined): void {
|
||||
if (previousFastTestEnv == null) {
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
return;
|
||||
}
|
||||
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
|
||||
}
|
||||
|
||||
export async function loadRunCronIsolatedAgentTurn() {
|
||||
const { runCronIsolatedAgentTurn } = await import("./run.js");
|
||||
return runCronIsolatedAgentTurn;
|
||||
}
|
||||
@@ -2,57 +2,35 @@ import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
|
||||
|
||||
function createGatewayWaitHarness() {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
return { emitter, disconnect, abort };
|
||||
}
|
||||
|
||||
function startGatewayWait(params?: {
|
||||
onGatewayError?: (error: unknown) => void;
|
||||
shouldStopOnError?: (error: unknown) => boolean;
|
||||
registerForceStop?: (fn: (error: unknown) => void) => void;
|
||||
}) {
|
||||
const harness = createGatewayWaitHarness();
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter: harness.emitter, disconnect: harness.disconnect },
|
||||
abortSignal: harness.abort.signal,
|
||||
...(params?.onGatewayError ? { onGatewayError: params.onGatewayError } : {}),
|
||||
...(params?.shouldStopOnError ? { shouldStopOnError: params.shouldStopOnError } : {}),
|
||||
...(params?.registerForceStop ? { registerForceStop: params.registerForceStop } : {}),
|
||||
});
|
||||
return { ...harness, promise };
|
||||
}
|
||||
|
||||
async function expectAbortToResolve(params: {
|
||||
emitter: EventEmitter;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
abort: AbortController;
|
||||
promise: Promise<void>;
|
||||
expectedDisconnectBeforeAbort?: number;
|
||||
}) {
|
||||
if (params.expectedDisconnectBeforeAbort !== undefined) {
|
||||
expect(params.disconnect).toHaveBeenCalledTimes(params.expectedDisconnectBeforeAbort);
|
||||
}
|
||||
expect(params.emitter.listenerCount("error")).toBe(1);
|
||||
params.abort.abort();
|
||||
await expect(params.promise).resolves.toBeUndefined();
|
||||
expect(params.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(params.emitter.listenerCount("error")).toBe(0);
|
||||
}
|
||||
|
||||
describe("waitForDiscordGatewayStop", () => {
|
||||
it("resolves on abort and disconnects gateway", async () => {
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait();
|
||||
await expectAbortToResolve({ emitter, disconnect, abort, promise });
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
expect(emitter.listenerCount("error")).toBe(1);
|
||||
abort.abort();
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects on gateway error and disconnects", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const onGatewayError = vi.fn();
|
||||
const abort = new AbortController();
|
||||
const err = new Error("boom");
|
||||
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
onGatewayError,
|
||||
});
|
||||
|
||||
@@ -68,23 +46,28 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
});
|
||||
|
||||
it("ignores gateway errors when instructed", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const onGatewayError = vi.fn();
|
||||
const abort = new AbortController();
|
||||
const err = new Error("transient");
|
||||
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
onGatewayError,
|
||||
shouldStopOnError: () => false,
|
||||
});
|
||||
|
||||
emitter.emit("error", err);
|
||||
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||
await expectAbortToResolve({
|
||||
emitter,
|
||||
disconnect,
|
||||
abort,
|
||||
promise,
|
||||
expectedDisconnectBeforeAbort: 0,
|
||||
});
|
||||
expect(disconnect).toHaveBeenCalledTimes(0);
|
||||
expect(emitter.listenerCount("error")).toBe(1);
|
||||
|
||||
abort.abort();
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves on abort without a gateway", async () => {
|
||||
@@ -100,9 +83,14 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
});
|
||||
|
||||
it("rejects via registerForceStop and disconnects gateway", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
let forceStop: ((err: unknown) => void) | undefined;
|
||||
|
||||
const { emitter, disconnect, promise } = startGatewayWait({
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
registerForceStop: (fn) => {
|
||||
forceStop = fn;
|
||||
},
|
||||
@@ -118,9 +106,14 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
});
|
||||
|
||||
it("ignores forceStop after promise already settled", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
let forceStop: ((err: unknown) => void) | undefined;
|
||||
|
||||
const { abort, disconnect, promise } = startGatewayWait({
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
registerForceStop: (fn) => {
|
||||
forceStop = fn;
|
||||
},
|
||||
|
||||
@@ -121,39 +121,32 @@ async function getFreeGatewayPort(): Promise<number> {
|
||||
|
||||
async function connectClient(params: { url: string; token: string }) {
|
||||
return await new Promise<GatewayClient>((resolve, reject) => {
|
||||
let done = false;
|
||||
const finish = (result: { client?: GatewayClient; error?: Error }) => {
|
||||
if (done) {
|
||||
let settled = false;
|
||||
const stop = (err?: Error, client?: GatewayClient) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
clearTimeout(connectTimeout);
|
||||
if (result.error) {
|
||||
reject(result.error);
|
||||
return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(client as GatewayClient);
|
||||
}
|
||||
resolve(result.client as GatewayClient);
|
||||
};
|
||||
|
||||
const failWithClose = (code: number, reason: string) =>
|
||||
finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) });
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
token: params.token,
|
||||
clientName: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientVersion: "dev",
|
||||
mode: "test",
|
||||
onHelloOk: () => finish({ client }),
|
||||
onConnectError: (error) => finish({ error }),
|
||||
onClose: failWithClose,
|
||||
onHelloOk: () => stop(undefined, client),
|
||||
onConnectError: (err) => stop(err),
|
||||
onClose: (code, reason) =>
|
||||
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
|
||||
});
|
||||
|
||||
const connectTimeout = setTimeout(
|
||||
() => finish({ error: new Error("gateway connect timeout") }),
|
||||
10_000,
|
||||
);
|
||||
connectTimeout.unref();
|
||||
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
|
||||
timer.unref();
|
||||
client.start();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,33 +325,20 @@ describe("gateway agent handler", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "passes senderIsOwner=false for write-scoped gateway callers",
|
||||
scopes: ["operator.write"],
|
||||
idempotencyKey: "test-sender-owner-write",
|
||||
senderIsOwner: false,
|
||||
},
|
||||
{
|
||||
name: "passes senderIsOwner=true for admin-scoped gateway callers",
|
||||
scopes: ["operator.admin"],
|
||||
idempotencyKey: "test-sender-owner-admin",
|
||||
senderIsOwner: true,
|
||||
},
|
||||
])("$name", async ({ scopes, idempotencyKey, senderIsOwner }) => {
|
||||
it("passes senderIsOwner=false for write-scoped gateway callers", async () => {
|
||||
primeMainAgentRun();
|
||||
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "owner-tools check",
|
||||
sessionKey: "agent:main:main",
|
||||
idempotencyKey,
|
||||
idempotencyKey: "test-sender-owner-write",
|
||||
},
|
||||
{
|
||||
client: {
|
||||
connect: {
|
||||
role: "operator",
|
||||
scopes,
|
||||
scopes: ["operator.write"],
|
||||
client: { id: "test-client", mode: "gateway" },
|
||||
},
|
||||
} as unknown as AgentHandlerArgs["client"],
|
||||
@@ -362,7 +349,34 @@ describe("gateway agent handler", () => {
|
||||
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
|
||||
| { senderIsOwner?: boolean }
|
||||
| undefined;
|
||||
expect(callArgs?.senderIsOwner).toBe(senderIsOwner);
|
||||
expect(callArgs?.senderIsOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("passes senderIsOwner=true for admin-scoped gateway callers", async () => {
|
||||
primeMainAgentRun();
|
||||
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "owner-tools check",
|
||||
sessionKey: "agent:main:main",
|
||||
idempotencyKey: "test-sender-owner-admin",
|
||||
},
|
||||
{
|
||||
client: {
|
||||
connect: {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
client: { id: "test-client", mode: "gateway" },
|
||||
},
|
||||
} as unknown as AgentHandlerArgs["client"],
|
||||
},
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
|
||||
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
|
||||
| { senderIsOwner?: boolean }
|
||||
| undefined;
|
||||
expect(callArgs?.senderIsOwner).toBe(true);
|
||||
});
|
||||
|
||||
it("respects explicit bestEffortDeliver=false for main session runs", async () => {
|
||||
|
||||
@@ -201,20 +201,6 @@ function expectNotFoundResponseAndNoWrite(respond: ReturnType<typeof vi.fn>) {
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function expectUnsafeWorkspaceFile(method: "agents.files.get" | "agents.files.set") {
|
||||
const params =
|
||||
method === "agents.files.set"
|
||||
? { agentId: "main", name: "AGENTS.md", content: "x" }
|
||||
: { agentId: "main", name: "AGENTS.md" };
|
||||
const { respond, promise } = makeCall(method, params);
|
||||
await promise;
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fsReadFile.mockImplementation(async () => {
|
||||
throw createEnoentError();
|
||||
@@ -531,7 +517,7 @@ describe("agents.files.get/set symlink safety", () => {
|
||||
mocks.fsMkdir.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function mockWorkspaceEscapeSymlink() {
|
||||
it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
@@ -550,21 +536,54 @@ describe("agents.files.get/set symlink safety", () => {
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ method: "agents.files.get" as const, expectNoOpen: false },
|
||||
{ method: "agents.files.set" as const, expectNoOpen: true },
|
||||
])(
|
||||
"rejects $method when allowlisted file symlink escapes workspace",
|
||||
async ({ method, expectNoOpen }) => {
|
||||
mockWorkspaceEscapeSymlink();
|
||||
await expectUnsafeWorkspaceFile(method);
|
||||
if (expectNoOpen) {
|
||||
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
||||
const { respond, promise } = makeCall("agents.files.get", {
|
||||
agentId: "main",
|
||||
name: "AGENTS.md",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
if (p === workspace) {
|
||||
return workspace;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (p === candidate) {
|
||||
return "/outside/secret.txt";
|
||||
}
|
||||
return p;
|
||||
});
|
||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
||||
const p = typeof args[0] === "string" ? args[0] : "";
|
||||
if (p === candidate) {
|
||||
return makeSymlinkStat();
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
|
||||
const { respond, promise } = makeCall("agents.files.set", {
|
||||
agentId: "main",
|
||||
name: "AGENTS.md",
|
||||
content: "x",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
||||
);
|
||||
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows in-workspace symlink targets for get/set", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
@@ -635,7 +654,7 @@ describe("agents.files.get/set symlink safety", () => {
|
||||
);
|
||||
});
|
||||
|
||||
function mockHardlinkedWorkspaceAlias() {
|
||||
it("rejects agents.files.get when allowlisted file is a hardlinked alias", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
@@ -651,19 +670,49 @@ describe("agents.files.get/set symlink safety", () => {
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ method: "agents.files.get" as const, expectNoOpen: false },
|
||||
{ method: "agents.files.set" as const, expectNoOpen: true },
|
||||
])(
|
||||
"rejects $method when allowlisted file is a hardlinked alias",
|
||||
async ({ method, expectNoOpen }) => {
|
||||
mockHardlinkedWorkspaceAlias();
|
||||
await expectUnsafeWorkspaceFile(method);
|
||||
if (expectNoOpen) {
|
||||
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
||||
const { respond, promise } = makeCall("agents.files.get", {
|
||||
agentId: "main",
|
||||
name: "AGENTS.md",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects agents.files.set when allowlisted file is a hardlinked alias", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
if (p === workspace) {
|
||||
return workspace;
|
||||
}
|
||||
},
|
||||
);
|
||||
return p;
|
||||
});
|
||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
||||
const p = typeof args[0] === "string" ? args[0] : "";
|
||||
if (p === candidate) {
|
||||
return makeFileStat({ nlink: 2 });
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
|
||||
const { respond, promise } = makeCall("agents.files.set", {
|
||||
agentId: "main",
|
||||
name: "AGENTS.md",
|
||||
content: "x",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
||||
);
|
||||
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user