Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b24bf7ac1 | ||
|
|
a61c8a2317 | ||
|
|
c717258a8f |
No files matched your search
@@ -29,6 +29,5 @@ jobs:
|
|||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
CLAWDBOT_NO_ONBOARD: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||||
run: pnpm test:install:smoke
|
run: pnpm test:install:smoke
|
||||||
@@ -22,15 +22,6 @@
|
|||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||||
|
|
||||||
## exe.dev VM ops (general)
|
|
||||||
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
|
|
||||||
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
|
|
||||||
- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset.
|
|
||||||
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
|
|
||||||
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
|
|
||||||
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
|
|
||||||
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
@@ -60,7 +51,6 @@
|
|||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||||
- Do not set test workers above 16; tried already.
|
|
||||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||||
- Full kit + what’s covered: `docs/testing.md`.
|
- Full kit + what’s covered: `docs/testing.md`.
|
||||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||||
|
|||||||
+17
-36
@@ -2,54 +2,48 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.23
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
|
||||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
|
||||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
|
||||||
- Gateway/Linux: include user + env-configured bin dirs in systemd PATH and align service audit checks. (#1512) Thanks @robbyczgw-cla.
|
|
||||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
|
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||||
|
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||||
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
|
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||||
|
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||||
|
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||||
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||||
|
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
||||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||||
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
|
||||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
|
||||||
- Gateway: stop the service before uninstalling and fail if it remains loaded.
|
|
||||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||||
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
|
|
||||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
||||||
- Agents: make tool summaries more readable and only show optional params when set.
|
- Agents: make tool summaries more readable and only show optional params when set.
|
||||||
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
||||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
|
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436) Thanks @sibbl.
|
||||||
- CLI: prefer `~` for home paths in output.
|
- CLI: prefer `~` for home paths in output.
|
||||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
||||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
|
||||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||||
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
|
|
||||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
||||||
|
|
||||||
## 2026.1.21-2
|
## 2026.1.21-2
|
||||||
|
|
||||||
@@ -60,8 +54,6 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||||
@@ -71,24 +63,13 @@ Docs: https://docs.clawd.bot
|
|||||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
|
||||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
|
||||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
|
||||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
|
||||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
|
||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
|
||||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
|
||||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
|
||||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
|
||||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
|
|||||||
+16
-48
@@ -2,54 +2,6 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
<item>
|
|
||||||
<title>2026.1.22</title>
|
|
||||||
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>7530</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
|
|
||||||
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
|
|
||||||
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
|
|
||||||
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
|
|
||||||
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
|
|
||||||
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
|
|
||||||
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
|
|
||||||
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
|
|
||||||
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
|
|
||||||
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
|
|
||||||
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
|
|
||||||
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
|
|
||||||
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
|
|
||||||
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
|
|
||||||
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
|
|
||||||
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
|
|
||||||
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
|
|
||||||
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
|
|
||||||
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
|
|
||||||
<li>CLI: prefer <code>~</code> for home paths in output.</li>
|
|
||||||
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
|
|
||||||
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
|
|
||||||
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
|
|
||||||
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
|
|
||||||
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
|
|
||||||
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
|
|
||||||
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
|
|
||||||
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
|
|
||||||
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.21</title>
|
<title>2026.1.21</title>
|
||||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
||||||
@@ -314,5 +266,21 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.16-2</title>
|
||||||
|
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>6273</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
||||||
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -102,9 +102,7 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
|
||||||
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
|
|
||||||
"It will only attach to an existing Gateway.")
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
|||||||
@@ -248,11 +248,12 @@ final class GatewayProcessManager {
|
|||||||
guard let linkId else {
|
guard let linkId else {
|
||||||
return "port \(port), health probe succeeded, \(instanceText)"
|
return "port \(port), health probe succeeded, \(instanceText)"
|
||||||
}
|
}
|
||||||
let linked = snap.channels[linkId]?.linked ?? false
|
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||||
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||||
let label =
|
let label =
|
||||||
snap.channelLabels?[linkId] ??
|
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||||
linkId.capitalized
|
linkId?.capitalized ??
|
||||||
|
"channel"
|
||||||
let linkText = linked ? "linked" : "not linked"
|
let linkText = linked ? "linked" : "not linked"
|
||||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-55
@@ -362,72 +362,23 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
|||||||
|
|
||||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||||
|
|
||||||
### Per-chat-type threading
|
### DM-specific threading
|
||||||
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
|
You can configure different threading behavior for DMs vs channels by setting `channels.slack.dm.replyToMode`:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
replyToMode: "off", // default for channels
|
replyToMode: "off", // default for channels
|
||||||
replyToModeByChatType: {
|
dm: {
|
||||||
direct: "all", // DMs always thread
|
replyToMode: "all" // DMs always thread
|
||||||
group: "first" // group DMs/MPIM thread first reply
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported chat types:
|
When `dm.replyToMode` is set, DMs use that mode; channels use the top-level `replyToMode`. If `dm.replyToMode` is not set, both DMs and channels use the top-level setting.
|
||||||
- `direct`: 1:1 DMs (Slack `im`)
|
|
||||||
- `group`: group DMs / MPIMs (Slack `mpim`)
|
|
||||||
- `channel`: standard channels (public/private)
|
|
||||||
|
|
||||||
Precedence:
|
|
||||||
1) `replyToModeByChatType.<chatType>`
|
|
||||||
2) `replyToMode`
|
|
||||||
3) Provider default (`off`)
|
|
||||||
|
|
||||||
Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
Thread DMs only:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
replyToMode: "off",
|
|
||||||
replyToModeByChatType: { direct: "all" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Thread group DMs but keep channels in the root:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
replyToMode: "off",
|
|
||||||
replyToModeByChatType: { group: "first" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Make channels thread, keep DMs in the root:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
replyToMode: "first",
|
|
||||||
replyToModeByChatType: { direct: "off", group: "off" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual threading tags
|
### Manual threading tags
|
||||||
For fine-grained control, use these tags in agent responses:
|
For fine-grained control, use these tags in agent responses:
|
||||||
|
|||||||
+4
-5
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
|
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to update a source checkout safely
|
- You want to update a source checkout safely
|
||||||
- You need to understand `--update` shorthand behavior
|
- You need to understand `--update` shorthand behavior
|
||||||
@@ -20,14 +20,14 @@ clawdbot update wizard
|
|||||||
clawdbot update --channel beta
|
clawdbot update --channel beta
|
||||||
clawdbot update --channel dev
|
clawdbot update --channel dev
|
||||||
clawdbot update --tag beta
|
clawdbot update --tag beta
|
||||||
clawdbot update --no-restart
|
clawdbot update --restart
|
||||||
clawdbot update --json
|
clawdbot update --json
|
||||||
clawdbot --update
|
clawdbot --update
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--no-restart`: skip restarting the Gateway service after a successful update.
|
- `--restart`: restart the Gateway service after a successful update.
|
||||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||||
@@ -52,8 +52,7 @@ Options:
|
|||||||
## `update wizard`
|
## `update wizard`
|
||||||
|
|
||||||
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
||||||
after updating (default is to restart). If you select `dev` without a git checkout, it
|
after updating. If you select `dev` without a git checkout, it offers to create one.
|
||||||
offers to create one.
|
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ stay consistent across channels.
|
|||||||
1. **Parse Markdown -> IR**
|
1. **Parse Markdown -> IR**
|
||||||
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
|
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
|
||||||
- Offsets are UTF-16 code units so Signal style ranges align with its API.
|
- Offsets are UTF-16 code units so Signal style ranges align with its API.
|
||||||
- Tables are parsed only when a channel opts into table conversion.
|
|
||||||
2. **Chunk IR (format-first)**
|
2. **Chunk IR (format-first)**
|
||||||
- Chunking happens on the IR text before rendering.
|
- Chunking happens on the IR text before rendering.
|
||||||
- Inline formatting does not split across chunks; spans are sliced per chunk.
|
- Inline formatting does not split across chunks; spans are sliced per chunk.
|
||||||
@@ -60,30 +59,7 @@ IR (schematic):
|
|||||||
|
|
||||||
- Slack, Telegram, and Signal outbound adapters render from the IR.
|
- Slack, Telegram, and Signal outbound adapters render from the IR.
|
||||||
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
|
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
|
||||||
their own formatting rules, with Markdown table conversion applied before
|
their own formatting rules.
|
||||||
chunking when enabled.
|
|
||||||
|
|
||||||
## Table handling
|
|
||||||
|
|
||||||
Markdown tables are not consistently supported across chat clients. Use
|
|
||||||
`markdown.tables` to control conversion per channel (and per account).
|
|
||||||
|
|
||||||
- `code`: render tables as code blocks (default for most channels).
|
|
||||||
- `bullets`: convert each row into bullet points (default for Signal + WhatsApp).
|
|
||||||
- `off`: disable table parsing and conversion; raw table text passes through.
|
|
||||||
|
|
||||||
Config keys:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
channels:
|
|
||||||
discord:
|
|
||||||
markdown:
|
|
||||||
tables: code
|
|
||||||
accounts:
|
|
||||||
work:
|
|
||||||
markdown:
|
|
||||||
tables: off
|
|
||||||
```
|
|
||||||
|
|
||||||
## Chunking rules
|
## Chunking rules
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
maxBytes: 20971520,
|
maxBytes: 20971520,
|
||||||
models: [
|
models: [
|
||||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
{ provider: "openai", model: "whisper-1" },
|
||||||
// Optional CLI fallback (Whisper binary):
|
// Optional CLI fallback (Whisper binary):
|
||||||
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1865,7 +1865,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
|||||||
- Each `models[]` entry:
|
- Each `models[]` entry:
|
||||||
- Provider entry (`type: "provider"` or omitted):
|
- Provider entry (`type: "provider"` or omitted):
|
||||||
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
|
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
|
||||||
- `model`: model id override (required for image; defaults to `gpt-4o-mini-transcribe`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
|
- `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
|
||||||
- `profile` / `preferredProfile`: auth profile selection.
|
- `profile` / `preferredProfile`: auth profile selection.
|
||||||
- CLI entry (`type: "cli"`):
|
- CLI entry (`type: "cli"`):
|
||||||
- `command`: executable to run.
|
- `command`: executable to run.
|
||||||
@@ -1890,7 +1890,7 @@ Example:
|
|||||||
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
{ provider: "openai", model: "whisper-1" },
|
||||||
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# Updating
|
# Updating
|
||||||
|
|
||||||
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify.
|
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
|
||||||
|
|
||||||
## Recommended: re-run the website installer (upgrade in place)
|
## Recommended: re-run the website installer (upgrade in place)
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Notes:
|
|||||||
For **source installs** (git checkout), prefer:
|
For **source installs** (git checkout), prefer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot update
|
clawdbot update --restart
|
||||||
```
|
```
|
||||||
|
|
||||||
It runs a safe-ish update flow:
|
It runs a safe-ish update flow:
|
||||||
@@ -89,7 +89,6 @@ It runs a safe-ish update flow:
|
|||||||
- Switches to the selected channel (tag or branch).
|
- Switches to the selected channel (tag or branch).
|
||||||
- Fetches + rebases against the configured upstream (dev channel).
|
- Fetches + rebases against the configured upstream (dev channel).
|
||||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||||
- Restarts the gateway by default (use `--no-restart` to skip).
|
|
||||||
|
|
||||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||||
|
|
||||||
|
|||||||
+3
-19
@@ -6,7 +6,7 @@ read_when:
|
|||||||
# Audio / Voice Notes — 2026-01-17
|
# Audio / Voice Notes — 2026-01-17
|
||||||
|
|
||||||
## What works
|
## What works
|
||||||
- **Media understanding (audio)**: If audio understanding is enabled (or auto‑detected), Clawdbot:
|
- **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot:
|
||||||
1) Locates the first audio attachment (local path or URL) and downloads it if needed.
|
1) Locates the first audio attachment (local path or URL) and downloads it if needed.
|
||||||
2) Enforces `maxBytes` before sending to each model entry.
|
2) Enforces `maxBytes` before sending to each model entry.
|
||||||
3) Runs the first eligible model entry in order (provider or CLI).
|
3) Runs the first eligible model entry in order (provider or CLI).
|
||||||
@@ -15,21 +15,6 @@ read_when:
|
|||||||
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
|
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
|
||||||
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
|
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
|
||||||
|
|
||||||
## Auto-detection (default)
|
|
||||||
If you **don’t configure models** and `tools.media.audio.enabled` is **not** set to `false`,
|
|
||||||
Clawdbot auto-detects in this order and stops at the first working option:
|
|
||||||
|
|
||||||
1) **Local CLIs** (if installed)
|
|
||||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
|
||||||
- `whisper-cli` (from `whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
|
|
||||||
- `whisper` (Python CLI; downloads models automatically)
|
|
||||||
2) **Gemini CLI** (`gemini`) using `read_many_files`
|
|
||||||
3) **Provider keys** (OpenAI → Groq → Deepgram → Google)
|
|
||||||
|
|
||||||
To disable auto-detection, set `tools.media.audio.enabled: false`.
|
|
||||||
To customize, set `tools.media.audio.models`.
|
|
||||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
|
||||||
|
|
||||||
## Config examples
|
## Config examples
|
||||||
|
|
||||||
### Provider + CLI fallback (OpenAI + Whisper CLI)
|
### Provider + CLI fallback (OpenAI + Whisper CLI)
|
||||||
@@ -41,7 +26,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
maxBytes: 20971520,
|
maxBytes: 20971520,
|
||||||
models: [
|
models: [
|
||||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
{ provider: "openai", model: "whisper-1" },
|
||||||
{
|
{
|
||||||
type: "cli",
|
type: "cli",
|
||||||
command: "whisper",
|
command: "whisper",
|
||||||
@@ -69,7 +54,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" }
|
{ provider: "openai", model: "whisper-1" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +83,6 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
|||||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||||
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
|
|
||||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||||
- Transcript is available to templates as `{{Transcript}}`.
|
- Transcript is available to templates as `{{Transcript}}`.
|
||||||
- CLI stdout is capped (5MB); keep CLI output concise.
|
- CLI stdout is capped (5MB); keep CLI output concise.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Media Understanding (Inbound) — 2026-01-17
|
# Media Understanding (Inbound) — 2026-01-17
|
||||||
|
|
||||||
Clawdbot can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual.
|
Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
|
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
|
||||||
@@ -88,11 +88,6 @@ Each `models[]` entry can be **provider** or **CLI**:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
CLI templates can also use:
|
|
||||||
- `{{MediaDir}}` (directory containing the media file)
|
|
||||||
- `{{OutputDir}}` (scratch dir created for this run)
|
|
||||||
- `{{OutputBase}}` (scratch file base path, no extension)
|
|
||||||
|
|
||||||
## Defaults and limits
|
## Defaults and limits
|
||||||
Recommended defaults:
|
Recommended defaults:
|
||||||
- `maxChars`: **500** for image/video (short, command‑friendly)
|
- `maxChars`: **500** for image/video (short, command‑friendly)
|
||||||
@@ -109,22 +104,17 @@ Rules:
|
|||||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||||
**active reply model** when its provider supports the capability.
|
**active reply model** when its provider supports the capability.
|
||||||
|
|
||||||
### Auto-detect media understanding (default)
|
### Auto-enable audio (when keys exist)
|
||||||
If `tools.media.<capability>.enabled` is **not** set to `false` and you haven’t
|
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
||||||
configured models, Clawdbot auto-detects in this order and **stops at the first
|
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
||||||
working option**:
|
even when you haven’t listed models explicitly.
|
||||||
|
|
||||||
1) **Local CLIs** (audio only; if installed)
|
Providers checked (in order):
|
||||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
1) OpenAI
|
||||||
- `whisper-cli` (`whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
|
2) Groq
|
||||||
- `whisper` (Python CLI; downloads models automatically)
|
3) Deepgram
|
||||||
2) **Gemini CLI** (`gemini`) using `read_many_files`
|
|
||||||
3) **Provider keys**
|
|
||||||
- Audio: OpenAI → Groq → Deepgram → Google
|
|
||||||
- Image: OpenAI → Anthropic → Google → MiniMax
|
|
||||||
- Video: Google
|
|
||||||
|
|
||||||
To disable auto-detection, set:
|
To disable this behavior, set:
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
tools: {
|
tools: {
|
||||||
@@ -136,7 +126,6 @@ To disable auto-detection, set:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
|
||||||
|
|
||||||
## Capabilities (optional)
|
## Capabilities (optional)
|
||||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||||
@@ -153,7 +142,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
|||||||
| Capability | Provider integration | Notes |
|
| Capability | Provider integration | Notes |
|
||||||
|------------|----------------------|-------|
|
|------------|----------------------|-------|
|
||||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||||
| Audio | OpenAI, Groq, Deepgram, Google | Provider transcription (Whisper/Deepgram/Gemini). |
|
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
||||||
| Video | Google (Gemini API) | Provider video understanding. |
|
| Video | Google (Gemini API) | Provider video understanding. |
|
||||||
|
|
||||||
## Recommended providers
|
## Recommended providers
|
||||||
@@ -162,8 +151,8 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
|||||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||||
|
|
||||||
**Audio**
|
**Audio**
|
||||||
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||||
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`.
|
- CLI fallback: `whisper` binary.
|
||||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||||
|
|
||||||
**Video**
|
**Video**
|
||||||
@@ -220,7 +209,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
|
|||||||
audio: {
|
audio: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
models: [
|
models: [
|
||||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
{ provider: "openai", model: "whisper-1" },
|
||||||
{
|
{
|
||||||
type: "cli",
|
type: "cli",
|
||||||
command: "whisper",
|
command: "whisper",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
|||||||
|
|
||||||
## Prereqs
|
## Prereqs
|
||||||
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
|
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
|
||||||
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
|
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist).
|
||||||
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
|
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
|
||||||
- We use a Keychain profile named `clawdbot-notary`, created from App Store Connect API key env vars in your shell profile:
|
- We use a Keychain profile named `clawdbot-notary`, created from App Store Connect API key env vars in your shell profile:
|
||||||
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
|
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
|
|||||||
```
|
```
|
||||||
|
|
||||||
Other config knobs that affect tool availability:
|
Other config knobs that affect tool availability:
|
||||||
- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain
|
|
||||||
enabled unless you also include core tools or groups in the allowlist.
|
|
||||||
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
|
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
|
||||||
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
|
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
|
||||||
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
|
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ provider in two different ways.
|
|||||||
|
|
||||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||||
|
|
||||||
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
Use the native device-login flow to obtain a GitHub token and use it directly
|
||||||
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
against the Copilot API. This is the **default** and simplest path because it
|
||||||
because it does not require VS Code.
|
does not require VS Code. Enterprise domains are supported.
|
||||||
|
|
||||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ clawdbot models auth login-github-copilot
|
|||||||
|
|
||||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||||
open until it completes.
|
open until it completes.
|
||||||
|
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
|
||||||
|
domain (for example `company.ghe.com`).
|
||||||
|
|
||||||
### Optional flags
|
### Optional flags
|
||||||
|
|
||||||
@@ -66,5 +68,7 @@ clawdbot models set github-copilot/gpt-4o
|
|||||||
- Requires an interactive TTY; run it directly in a terminal.
|
- Requires an interactive TTY; run it directly in a terminal.
|
||||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||||
another ID (for example `github-copilot/gpt-4.1`).
|
another ID (for example `github-copilot/gpt-4.1`).
|
||||||
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
- The login stores a GitHub token in the auth profile store and uses it directly
|
||||||
Copilot API token when Clawdbot runs.
|
for Copilot API calls.
|
||||||
|
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
|
||||||
|
for GitHub Enterprise.
|
||||||
@@ -13,7 +13,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
|||||||
## Operator trigger
|
## Operator trigger
|
||||||
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
||||||
- Read this doc and `docs/platforms/mac/release.md`.
|
- Read this doc and `docs/platforms/mac/release.md`.
|
||||||
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
|
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
|
||||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||||
|
|
||||||
1) **Version & metadata**
|
1) **Version & metadata**
|
||||||
@@ -39,9 +39,8 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
||||||
- [ ] `pnpm run build` (last sanity check after tests)
|
- [ ] `pnpm run build` (last sanity check after tests)
|
||||||
- [ ] `pnpm release:check` (verifies npm pack contents)
|
- [ ] `pnpm release:check` (verifies npm pack contents)
|
||||||
- [ ] `CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
|
- [ ] `pnpm test:install:smoke` (Docker install smoke test; required before release)
|
||||||
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
||||||
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
|
|
||||||
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
|
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
|
||||||
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
||||||
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
||||||
|
|||||||
@@ -121,10 +121,6 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
|
|||||||
|
|
||||||
You can also allow it globally with `tools.allow` if every agent should see it.
|
You can also allow it globally with `tools.allow` if every agent should see it.
|
||||||
|
|
||||||
Note: allowlists are opt-in for optional plugins. If your allowlist only names
|
|
||||||
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
|
|
||||||
tools, include the core tools or groups you want in the allowlist too.
|
|
||||||
|
|
||||||
## Example: Email triage
|
## Example: Email triage
|
||||||
|
|
||||||
Without Lobster:
|
Without Lobster:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -26,7 +25,6 @@ const bluebubblesGroupConfigSchema = z.object({
|
|||||||
const bluebubblesAccountSchema = z.object({
|
const bluebubblesAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
|
||||||
serverUrl: z.string().optional(),
|
serverUrl: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
|
|||||||
@@ -99,8 +99,6 @@ function createMockRuntime(): PluginRuntime {
|
|||||||
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
||||||
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
||||||
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
|
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
|
||||||
resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
|
||||||
convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
|
|
||||||
},
|
},
|
||||||
reply: {
|
reply: {
|
||||||
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||||
@@ -222,12 +220,6 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flushAsync = async () => {
|
|
||||||
for (let i = 0; i < 2; i += 1) {
|
|
||||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("BlueBubbles webhook monitor", () => {
|
describe("BlueBubbles webhook monitor", () => {
|
||||||
let unregister: () => void;
|
let unregister: () => void;
|
||||||
|
|
||||||
@@ -514,7 +506,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -562,7 +554,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||||
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||||
@@ -609,7 +601,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
|
||||||
// Wait for async processing
|
// Wait for async processing
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
@@ -648,7 +640,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
@@ -689,7 +681,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
@@ -732,7 +724,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||||
// Should not send pairing reply since created=false
|
// Should not send pairing reply since created=false
|
||||||
@@ -773,7 +765,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -810,7 +802,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -850,7 +842,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -888,7 +880,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -927,7 +919,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -966,7 +958,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1007,7 +999,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1048,7 +1040,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1086,7 +1078,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1129,7 +1121,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1175,7 +1167,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1221,7 +1213,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const originalRes = createMockResponse();
|
const originalRes = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Only assert the reply message behavior below.
|
// Only assert the reply message behavior below.
|
||||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||||
@@ -1245,7 +1237,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const replyRes = createMockResponse();
|
const replyRes = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1291,7 +1283,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1339,7 +1331,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -1392,7 +1384,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Should process even without mention because it's an authorized control command
|
// Should process even without mention because it's an authorized control command
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
@@ -1435,7 +1427,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1478,7 +1470,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(markBlueBubblesChatRead).toHaveBeenCalled();
|
expect(markBlueBubblesChatRead).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1519,7 +1511,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
|
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1562,7 +1554,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Should call typing start when reply flow triggers it.
|
// Should call typing start when reply flow triggers it.
|
||||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||||
@@ -1612,7 +1604,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
@@ -1657,7 +1649,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
@@ -1705,7 +1697,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
@@ -1750,7 +1742,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("reaction added"),
|
expect.stringContaining("reaction added"),
|
||||||
@@ -1790,7 +1782,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("reaction removed"),
|
expect.stringContaining("reaction removed"),
|
||||||
@@ -1830,7 +1822,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1868,7 +1860,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("👍"),
|
expect.stringContaining("👍"),
|
||||||
@@ -1909,7 +1901,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1949,7 +1941,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// The short ID "1" should resolve back to the full UUID
|
// The short ID "1" should resolve back to the full UUID
|
||||||
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
||||||
@@ -2001,7 +1993,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1662,15 +1662,9 @@ async function processMessage(
|
|||||||
? [payload.mediaUrl]
|
? [payload.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
if (mediaList.length > 0) {
|
if (mediaList.length > 0) {
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg: config,
|
|
||||||
channel: "bluebubbles",
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? text : undefined;
|
const caption = first ? payload.text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
const result = await sendBlueBubblesMedia({
|
const result = await sendBlueBubblesMedia({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -1692,14 +1686,8 @@ async function processMessage(
|
|||||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||||
? account.config.textChunkLimit
|
? account.config.textChunkLimit
|
||||||
: DEFAULT_TEXT_LIMIT;
|
: DEFAULT_TEXT_LIMIT;
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit);
|
||||||
cfg: config,
|
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||||
channel: "bluebubbles",
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
|
||||||
if (!chunks.length && text) chunks.push(text);
|
|
||||||
if (!chunks.length) return;
|
if (!chunks.length) return;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -36,7 +35,6 @@ const matrixRoomSchema = z
|
|||||||
export const MatrixConfigSchema = z.object({
|
export const MatrixConfigSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
|
||||||
homeserver: z.string().optional(),
|
homeserver: z.string().optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
accessToken: z.string().optional(),
|
accessToken: z.string().optional(),
|
||||||
|
|||||||
@@ -548,11 +548,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
}
|
}
|
||||||
|
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "matrix",
|
|
||||||
accountId: route.accountId,
|
|
||||||
});
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||||
@@ -567,8 +562,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
textLimit,
|
textLimit,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
threadId: threadTarget,
|
threadId: threadTarget,
|
||||||
accountId: route.accountId,
|
|
||||||
tableMode,
|
|
||||||
});
|
});
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MatrixClient } from "matrix-bot-sdk";
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import { sendMessageMatrix } from "../send.js";
|
import { sendMessageMatrix } from "../send.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
@@ -12,17 +12,8 @@ export async function deliverMatrixReplies(params: {
|
|||||||
textLimit: number;
|
textLimit: number;
|
||||||
replyToMode: "off" | "first" | "all";
|
replyToMode: "off" | "first" | "all";
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
accountId?: string;
|
|
||||||
tableMode?: MarkdownTableMode;
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const core = getMatrixRuntime();
|
const core = getMatrixRuntime();
|
||||||
const tableMode =
|
|
||||||
params.tableMode ??
|
|
||||||
core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg: core.config.loadConfig(),
|
|
||||||
channel: "matrix",
|
|
||||||
accountId: params.accountId,
|
|
||||||
});
|
|
||||||
const logVerbose = (message: string) => {
|
const logVerbose = (message: string) => {
|
||||||
if (core.logging.shouldLogVerbose()) {
|
if (core.logging.shouldLogVerbose()) {
|
||||||
params.runtime.log?.(message);
|
params.runtime.log?.(message);
|
||||||
@@ -42,8 +33,6 @@ export async function deliverMatrixReplies(params: {
|
|||||||
}
|
}
|
||||||
const replyToIdRaw = reply.replyToId?.trim();
|
const replyToIdRaw = reply.replyToId?.trim();
|
||||||
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
||||||
const rawText = reply.text ?? "";
|
|
||||||
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
|
|
||||||
const mediaList = reply.mediaUrls?.length
|
const mediaList = reply.mediaUrls?.length
|
||||||
? reply.mediaUrls
|
? reply.mediaUrls
|
||||||
: reply.mediaUrl
|
: reply.mediaUrl
|
||||||
@@ -54,14 +43,13 @@ export async function deliverMatrixReplies(params: {
|
|||||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
|
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageMatrix(params.roomId, trimmed, {
|
await sendMessageMatrix(params.roomId, trimmed, {
|
||||||
client: params.client,
|
client: params.client,
|
||||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||||
threadId: params.threadId,
|
threadId: params.threadId,
|
||||||
accountId: params.accountId,
|
|
||||||
});
|
});
|
||||||
if (shouldIncludeReply(replyToId)) {
|
if (shouldIncludeReply(replyToId)) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
@@ -72,14 +60,13 @@ export async function deliverMatrixReplies(params: {
|
|||||||
|
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? text : "";
|
const caption = first ? (reply.text ?? "") : "";
|
||||||
await sendMessageMatrix(params.roomId, caption, {
|
await sendMessageMatrix(params.roomId, caption, {
|
||||||
client: params.client,
|
client: params.client,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||||
threadId: params.threadId,
|
threadId: params.threadId,
|
||||||
audioAsVoice: reply.audioAsVoice,
|
audioAsVoice: reply.audioAsVoice,
|
||||||
accountId: params.accountId,
|
|
||||||
});
|
});
|
||||||
if (shouldIncludeReply(replyToId)) {
|
if (shouldIncludeReply(replyToId)) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ const runtimeStub = {
|
|||||||
text: {
|
text: {
|
||||||
resolveTextChunkLimit: () => 4000,
|
resolveTextChunkLimit: () => 4000,
|
||||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||||
resolveMarkdownTableMode: () => "code",
|
|
||||||
convertMarkdownTables: (text: string) => text,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as PluginRuntime;
|
} as unknown as PluginRuntime;
|
||||||
|
|||||||
@@ -50,18 +50,9 @@ export async function sendMessageMatrix(
|
|||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
const cfg = getCore().config.loadConfig();
|
const cfg = getCore().config.loadConfig();
|
||||||
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "matrix",
|
|
||||||
accountId: opts.accountId,
|
|
||||||
});
|
|
||||||
const convertedMessage = getCore().channel.text.convertMarkdownTables(
|
|
||||||
trimmedMessage,
|
|
||||||
tableMode,
|
|
||||||
);
|
|
||||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||||
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
|
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||||
const threadId = normalizeThreadId(opts.threadId);
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const relation = threadId
|
const relation = threadId
|
||||||
? buildThreadRelation(threadId, opts.replyToId)
|
? buildThreadRelation(threadId, opts.replyToId)
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export type MatrixSendResult = {
|
|||||||
export type MatrixSendOpts = {
|
export type MatrixSendOpts = {
|
||||||
client?: import("matrix-bot-sdk").MatrixClient;
|
client?: import("matrix-bot-sdk").MatrixClient;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
accountId?: string;
|
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
threadId?: string | number | null;
|
threadId?: string | number | null;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/mattermost",
|
"name": "@clawdbot/mattermost",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.20-2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Mattermost channel plugin",
|
"description": "Clawdbot Mattermost channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
BlockStreamingCoalesceSchema,
|
BlockStreamingCoalesceSchema,
|
||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
MarkdownConfigSchema,
|
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
@@ -12,7 +11,6 @@ const MattermostAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
|
|||||||
@@ -707,11 +707,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||||
});
|
});
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "mattermost",
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
|
|
||||||
let prefixContext: ResponsePrefixContext = {
|
let prefixContext: ResponsePrefixContext = {
|
||||||
identityName: resolveIdentityName(cfg, route.agentId),
|
identityName: resolveIdentityName(cfg, route.agentId),
|
||||||
@@ -725,7 +720,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload: ReplyPayload) => {
|
deliver: async (payload: ReplyPayload) => {
|
||||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = payload.text ?? "";
|
||||||
if (mediaUrls.length === 0) {
|
if (mediaUrls.length === 0) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||||
|
|||||||
@@ -181,15 +181,6 @@ export async function sendMessageMattermost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message) {
|
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "mattermost",
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
message = core.channel.text.convertMarkdownTables(message, tableMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
||||||
@@ -214,4 +205,4 @@ export async function sendMessageMattermost(
|
|||||||
messageId: post.id ?? "unknown",
|
messageId: post.id ?? "unknown",
|
||||||
channelId,
|
channelId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -14,12 +14,11 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
|
// Skip if no OpenAI API key
|
||||||
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
|
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
const liveEnabled = HAS_OPENAI_KEY && process.env.CLAWDBOT_LIVE_TEST === "1";
|
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
|
||||||
const describeLive = liveEnabled ? describe : describe.skip;
|
|
||||||
|
|
||||||
describe("memory plugin e2e", () => {
|
describeWithKey("memory plugin e2e", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let dbPath: string;
|
let dbPath: string;
|
||||||
|
|
||||||
@@ -160,7 +159,7 @@ describe("memory plugin e2e", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Live tests that require OpenAI API key and actually use LanceDB
|
// Live tests that require OpenAI API key and actually use LanceDB
|
||||||
describeLive("memory plugin live tests", () => {
|
describeWithKey("memory plugin live tests", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let dbPath: string;
|
let dbPath: string;
|
||||||
|
|
||||||
@@ -177,7 +176,6 @@ describeLive("memory plugin live tests", () => {
|
|||||||
|
|
||||||
test("memory tools work end-to-end", async () => {
|
test("memory tools work end-to-end", async () => {
|
||||||
const { default: memoryPlugin } = await import("./index.js");
|
const { default: memoryPlugin } = await import("./index.js");
|
||||||
const liveApiKey = process.env.OPENAI_API_KEY ?? "";
|
|
||||||
|
|
||||||
// Mock plugin API
|
// Mock plugin API
|
||||||
const registeredTools: any[] = [];
|
const registeredTools: any[] = [];
|
||||||
@@ -193,7 +191,7 @@ describeLive("memory plugin live tests", () => {
|
|||||||
config: {},
|
config: {},
|
||||||
pluginConfig: {
|
pluginConfig: {
|
||||||
embedding: {
|
embedding: {
|
||||||
apiKey: liveApiKey,
|
apiKey: OPENAI_API_KEY,
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
},
|
},
|
||||||
dbPath,
|
dbPath,
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ const runtimeStub = {
|
|||||||
}
|
}
|
||||||
return chunks;
|
return chunks;
|
||||||
},
|
},
|
||||||
resolveMarkdownTableMode: () => "code",
|
|
||||||
convertMarkdownTables: (text: string) => text,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as PluginRuntime;
|
} as unknown as PluginRuntime;
|
||||||
@@ -36,7 +34,6 @@ describe("msteams messenger", () => {
|
|||||||
it("filters silent replies", () => {
|
it("filters silent replies", () => {
|
||||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
tableMode: "code",
|
|
||||||
});
|
});
|
||||||
expect(messages).toEqual([]);
|
expect(messages).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -44,7 +41,7 @@ describe("msteams messenger", () => {
|
|||||||
it("filters silent reply prefixes", () => {
|
it("filters silent reply prefixes", () => {
|
||||||
const messages = renderReplyPayloadsToMessages(
|
const messages = renderReplyPayloadsToMessages(
|
||||||
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
|
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
|
||||||
{ textChunkLimit: 4000, tableMode: "code" },
|
{ textChunkLimit: 4000 },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([]);
|
expect(messages).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -52,7 +49,7 @@ describe("msteams messenger", () => {
|
|||||||
it("splits media into separate messages by default", () => {
|
it("splits media into separate messages by default", () => {
|
||||||
const messages = renderReplyPayloadsToMessages(
|
const messages = renderReplyPayloadsToMessages(
|
||||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||||
{ textChunkLimit: 4000, tableMode: "code" },
|
{ textChunkLimit: 4000 },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
||||||
});
|
});
|
||||||
@@ -60,7 +57,7 @@ describe("msteams messenger", () => {
|
|||||||
it("supports inline media mode", () => {
|
it("supports inline media mode", () => {
|
||||||
const messages = renderReplyPayloadsToMessages(
|
const messages = renderReplyPayloadsToMessages(
|
||||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||||
{ textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
|
{ textChunkLimit: 4000, mediaMode: "inline" },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
||||||
});
|
});
|
||||||
@@ -69,7 +66,6 @@ describe("msteams messenger", () => {
|
|||||||
const long = "hello ".repeat(200);
|
const long = "hello ".repeat(200);
|
||||||
const messages = renderReplyPayloadsToMessages([{ text: long }], {
|
const messages = renderReplyPayloadsToMessages([{ text: long }], {
|
||||||
textChunkLimit: 50,
|
textChunkLimit: 50,
|
||||||
tableMode: "code",
|
|
||||||
});
|
});
|
||||||
expect(messages.length).toBeGreaterThan(1);
|
expect(messages.length).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
isSilentReplyText,
|
isSilentReplyText,
|
||||||
loadWebMedia,
|
loadWebMedia,
|
||||||
type MarkdownTableMode,
|
|
||||||
type MSTeamsReplyStyle,
|
type MSTeamsReplyStyle,
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
SILENT_REPLY_TOKEN,
|
SILENT_REPLY_TOKEN,
|
||||||
@@ -62,7 +61,6 @@ export type MSTeamsReplyRenderOptions = {
|
|||||||
textChunkLimit: number;
|
textChunkLimit: number;
|
||||||
chunkText?: boolean;
|
chunkText?: boolean;
|
||||||
mediaMode?: "split" | "inline";
|
mediaMode?: "split" | "inline";
|
||||||
tableMode?: MarkdownTableMode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,19 +196,10 @@ export function renderReplyPayloadsToMessages(
|
|||||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||||
const chunkText = options.chunkText !== false;
|
const chunkText = options.chunkText !== false;
|
||||||
const mediaMode = options.mediaMode ?? "split";
|
const mediaMode = options.mediaMode ?? "split";
|
||||||
const tableMode =
|
|
||||||
options.tableMode ??
|
|
||||||
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg: getMSTeamsRuntime().config.loadConfig(),
|
|
||||||
channel: "msteams",
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const payload of replies) {
|
for (const payload of replies) {
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
const text = payload.text ?? "";
|
||||||
payload.text ?? "",
|
|
||||||
tableMode,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,10 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
).responsePrefix,
|
).responsePrefix,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg: params.cfg,
|
|
||||||
channel: "msteams",
|
|
||||||
});
|
|
||||||
const messages = renderReplyPayloadsToMessages([payload], {
|
const messages = renderReplyPayloadsToMessages([payload], {
|
||||||
textChunkLimit: params.textLimit,
|
textChunkLimit: params.textLimit,
|
||||||
chunkText: true,
|
chunkText: true,
|
||||||
mediaMode: "split",
|
mediaMode: "split",
|
||||||
tableMode,
|
|
||||||
});
|
});
|
||||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
||||||
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
|
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
|
||||||
import { buildMSTeamsPollCard } from "./polls.js";
|
import { buildMSTeamsPollCard } from "./polls.js";
|
||||||
import { getMSTeamsRuntime } from "./runtime.js";
|
|
||||||
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
||||||
|
|
||||||
export type SendMSTeamsMessageParams = {
|
export type SendMSTeamsMessageParams = {
|
||||||
@@ -94,21 +93,13 @@ export async function sendMessageMSTeams(
|
|||||||
params: SendMSTeamsMessageParams,
|
params: SendMSTeamsMessageParams,
|
||||||
): Promise<SendMSTeamsMessageResult> {
|
): Promise<SendMSTeamsMessageResult> {
|
||||||
const { cfg, to, text, mediaUrl } = params;
|
const { cfg, to, text, mediaUrl } = params;
|
||||||
const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "msteams",
|
|
||||||
});
|
|
||||||
const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
|
||||||
text ?? "",
|
|
||||||
tableMode,
|
|
||||||
);
|
|
||||||
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
||||||
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
|
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
|
||||||
|
|
||||||
log.debug("sending proactive message", {
|
log.debug("sending proactive message", {
|
||||||
conversationId,
|
conversationId,
|
||||||
conversationType,
|
conversationType,
|
||||||
textLength: messageText.length,
|
textLength: text.length,
|
||||||
hasMedia: Boolean(mediaUrl),
|
hasMedia: Boolean(mediaUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,7 +134,7 @@ export async function sendMessageMSTeams(
|
|||||||
const { activity, uploadId } = prepareFileConsentActivity({
|
const { activity, uploadId } = prepareFileConsentActivity({
|
||||||
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
|
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
|
||||||
conversationId,
|
conversationId,
|
||||||
description: messageText || undefined,
|
description: text || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
||||||
@@ -181,14 +172,14 @@ export async function sendMessageMSTeams(
|
|||||||
const base64 = media.buffer.toString("base64");
|
const base64 = media.buffer.toString("base64");
|
||||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||||
|
|
||||||
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
return sendTextWithMedia(ctx, text, finalMediaUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isImage && !sharePointSiteId) {
|
if (isImage && !sharePointSiteId) {
|
||||||
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
|
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
|
||||||
const base64 = media.buffer.toString("base64");
|
const base64 = media.buffer.toString("base64");
|
||||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||||
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
return sendTextWithMedia(ctx, text, finalMediaUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
|
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
|
||||||
@@ -232,7 +223,7 @@ export async function sendMessageMSTeams(
|
|||||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||||
const activity = {
|
const activity = {
|
||||||
type: "message",
|
type: "message",
|
||||||
text: messageText || undefined,
|
text: text || undefined,
|
||||||
attachments: [fileCardAttachment],
|
attachments: [fileCardAttachment],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,7 +264,7 @@ export async function sendMessageMSTeams(
|
|||||||
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
||||||
const activity = {
|
const activity = {
|
||||||
type: "message",
|
type: "message",
|
||||||
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
text: text ? `${text}\n\n${fileLink}` : fileLink,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRef = buildConversationReference(ref);
|
const baseRef = buildConversationReference(ref);
|
||||||
@@ -299,7 +290,7 @@ export async function sendMessageMSTeams(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No media: send text only
|
// No media: send text only
|
||||||
return sendTextWithMedia(ctx, messageText, undefined);
|
return sendTextWithMedia(ctx, text, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
DmConfigSchema,
|
DmConfigSchema,
|
||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
MarkdownConfigSchema,
|
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -22,7 +21,6 @@ export const NextcloudTalkAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
|
||||||
baseUrl: z.string().optional(),
|
baseUrl: z.string().optional(),
|
||||||
botSecret: z.string().optional(),
|
botSecret: z.string().optional(),
|
||||||
botSecretFile: z.string().optional(),
|
botSecretFile: z.string().optional(),
|
||||||
|
|||||||
@@ -71,18 +71,8 @@ export async function sendMessageNextcloudTalk(
|
|||||||
throw new Error("Message must be non-empty for Nextcloud Talk sends");
|
throw new Error("Message must be non-empty for Nextcloud Talk sends");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "nextcloud-talk",
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables(
|
|
||||||
text.trim(),
|
|
||||||
tableMode,
|
|
||||||
);
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
message,
|
message: text.trim(),
|
||||||
};
|
};
|
||||||
if (opts.replyTo) {
|
if (opts.replyTo) {
|
||||||
body.replyTo = opts.replyTo;
|
body.replyTo = opts.replyTo;
|
||||||
|
|||||||
@@ -133,20 +133,13 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId }) => {
|
sendText: async ({ to, text, accountId }) => {
|
||||||
const core = getNostrRuntime();
|
|
||||||
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const bus = activeBuses.get(aid);
|
const bus = activeBuses.get(aid);
|
||||||
if (!bus) {
|
if (!bus) {
|
||||||
throw new Error(`Nostr bus not running for account ${aid}`);
|
throw new Error(`Nostr bus not running for account ${aid}`);
|
||||||
}
|
}
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg: core.config.loadConfig(),
|
|
||||||
channel: "nostr",
|
|
||||||
accountId: aid,
|
|
||||||
});
|
|
||||||
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
|
||||||
const normalizedTo = normalizePubkey(to);
|
const normalizedTo = normalizePubkey(to);
|
||||||
await bus.sendDm(normalizedTo, message);
|
await bus.sendDm(normalizedTo, text);
|
||||||
return { channel: "nostr", to: normalizedTo };
|
return { channel: "nostr", to: normalizedTo };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
|
|
||||||
@@ -63,9 +63,6 @@ export const NostrConfigSchema = z.object({
|
|||||||
/** Whether this channel is enabled */
|
/** Whether this channel is enabled */
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
|
||||||
/** Markdown formatting overrides (tables). */
|
|
||||||
markdown: MarkdownConfigSchema,
|
|
||||||
|
|
||||||
/** Private key in hex or nsec bech32 format */
|
/** Private key in hex or nsec bech32 format */
|
||||||
privateKey: z.string().optional(),
|
privateKey: z.string().optional(),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/open-prose",
|
"name": "@clawdbot/open-prose",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.23",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -6,7 +5,6 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
|||||||
const zaloAccountSchema = z.object({
|
const zaloAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
tokenFile: z.string().optional(),
|
tokenFile: z.string().optional(),
|
||||||
webhookUrl: z.string().optional(),
|
webhookUrl: z.string().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
@@ -578,12 +578,6 @@ async function processMessageWithPipeline(params: {
|
|||||||
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg: config,
|
|
||||||
channel: "zalo",
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -597,7 +591,6 @@ async function processMessageWithPipeline(params: {
|
|||||||
core,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
tableMode,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
@@ -615,11 +608,8 @@ async function deliverZaloReply(params: {
|
|||||||
core: ZaloCoreRuntime;
|
core: ZaloCoreRuntime;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
fetcher?: ZaloFetch;
|
fetcher?: ZaloFetch;
|
||||||
tableMode?: MarkdownTableMode;
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
|
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
|
||||||
const tableMode = params.tableMode ?? "code";
|
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
||||||
|
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
@@ -630,7 +620,7 @@ async function deliverZaloReply(params: {
|
|||||||
if (mediaList.length > 0) {
|
if (mediaList.length > 0) {
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? text : undefined;
|
const caption = first ? payload.text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
try {
|
try {
|
||||||
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
||||||
@@ -642,8 +632,8 @@ async function deliverZaloReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (payload.text) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
|
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -11,7 +10,6 @@ const groupConfigSchema = z.object({
|
|||||||
const zalouserAccountSchema = z.object({
|
const zalouserAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
|
||||||
profile: z.string().optional(),
|
profile: z.string().optional(),
|
||||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||||
allowFrom: z.array(allowFromEntry).optional(),
|
allowFrom: z.array(allowFromEntry).optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
|
||||||
import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
|
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
|
||||||
import { sendMessageZalouser } from "./send.js";
|
import { sendMessageZalouser } from "./send.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -332,11 +332,6 @@ async function processMessage(
|
|||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
tableMode: core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg: config,
|
|
||||||
channel: "zalouser",
|
|
||||||
accountId: account.accountId,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
@@ -356,11 +351,8 @@ async function deliverZalouserReply(params: {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
core: ZalouserCoreRuntime;
|
core: ZalouserCoreRuntime;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
tableMode?: MarkdownTableMode;
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
|
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
|
||||||
const tableMode = params.tableMode ?? "code";
|
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
||||||
|
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
@@ -371,7 +363,7 @@ async function deliverZalouserReply(params: {
|
|||||||
if (mediaList.length > 0) {
|
if (mediaList.length > 0) {
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? text : undefined;
|
const caption = first ? payload.text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
try {
|
try {
|
||||||
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
||||||
@@ -388,8 +380,8 @@ async function deliverZalouserReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (payload.text) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
|
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
|
||||||
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@
|
|||||||
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
||||||
"format:all": "pnpm format && pnpm format:swift",
|
"format:all": "pnpm format && pnpm format:swift",
|
||||||
"format:fix": "oxfmt --write src test",
|
"format:fix": "oxfmt --write src test",
|
||||||
"test": "node scripts/test-parallel.mjs",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "pnpm --dir ui test",
|
"test:ui": "pnpm --dir ui test",
|
||||||
"test:force": "node --import tsx scripts/test-force.ts",
|
"test:force": "node --import tsx scripts/test-force.ts",
|
||||||
|
|||||||
@@ -19,12 +19,7 @@ echo "==> Verify git installed"
|
|||||||
command -v git >/dev/null
|
command -v git >/dev/null
|
||||||
|
|
||||||
echo "==> Verify clawdbot installed"
|
echo "==> Verify clawdbot installed"
|
||||||
EXPECTED_VERSION="${CLAWDBOT_INSTALL_EXPECT_VERSION:-}"
|
LATEST_VERSION="$(npm view clawdbot version)"
|
||||||
if [[ -n "$EXPECTED_VERSION" ]]; then
|
|
||||||
LATEST_VERSION="$EXPECTED_VERSION"
|
|
||||||
else
|
|
||||||
LATEST_VERSION="$(npm view clawdbot version)"
|
|
||||||
fi
|
|
||||||
CMD_PATH="$(command -v clawdbot || true)"
|
CMD_PATH="$(command -v clawdbot || true)"
|
||||||
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then
|
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then
|
||||||
CMD_PATH="$HOME/.npm-global/bin/clawdbot"
|
CMD_PATH="$HOME/.npm-global/bin/clawdbot"
|
||||||
|
|||||||
@@ -6,36 +6,21 @@ SMOKE_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}"
|
|||||||
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
|
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
|
||||||
|
|
||||||
echo "==> Resolve npm versions"
|
echo "==> Resolve npm versions"
|
||||||
|
LATEST_VERSION="$(npm view clawdbot version)"
|
||||||
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
|
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
|
||||||
LATEST_VERSION="$(npm view clawdbot version)"
|
|
||||||
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
|
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
|
||||||
else
|
else
|
||||||
VERSIONS_JSON="$(npm view clawdbot versions --json)"
|
PREVIOUS_VERSION="$(node - <<'NODE'
|
||||||
versions_line="$(node - <<'NODE'
|
const { execSync } = require("node:child_process");
|
||||||
const raw = process.env.VERSIONS_JSON || "[]";
|
|
||||||
let versions;
|
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
|
||||||
try {
|
if (!Array.isArray(versions) || versions.length === 0) {
|
||||||
versions = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
versions = raw ? [raw] : [];
|
|
||||||
}
|
|
||||||
if (!Array.isArray(versions)) {
|
|
||||||
versions = [versions];
|
|
||||||
}
|
|
||||||
if (versions.length === 0) {
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const latest = versions[versions.length - 1];
|
const previous = versions.length >= 2 ? versions[versions.length - 2] : versions[0];
|
||||||
const previous = versions.length >= 2 ? versions[versions.length - 2] : latest;
|
process.stdout.write(previous);
|
||||||
process.stdout.write(`${latest} ${previous}`);
|
|
||||||
NODE
|
NODE
|
||||||
)"
|
)"
|
||||||
LATEST_VERSION="${versions_line%% *}"
|
|
||||||
PREVIOUS_VERSION="${versions_line#* }"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${CLAWDBOT_INSTALL_LATEST_OUT:-}" ]]; then
|
|
||||||
printf "%s" "$LATEST_VERSION" > "$CLAWDBOT_INSTALL_LATEST_OUT"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
|
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ SMOKE_IMAGE="${CLAWDBOT_INSTALL_SMOKE_IMAGE:-clawdbot-install-smoke:local}"
|
|||||||
NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}"
|
NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}"
|
||||||
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
|
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
|
||||||
CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}"
|
CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}"
|
||||||
SKIP_NONROOT="${CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT:-0}"
|
|
||||||
LATEST_DIR="$(mktemp -d)"
|
|
||||||
LATEST_FILE="${LATEST_DIR}/latest"
|
|
||||||
|
|
||||||
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
|
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
|
||||||
docker build \
|
docker build \
|
||||||
@@ -18,48 +15,31 @@ docker build \
|
|||||||
|
|
||||||
echo "==> Run installer smoke test (root): $INSTALL_URL"
|
echo "==> Run installer smoke test (root): $INSTALL_URL"
|
||||||
docker run --rm -t \
|
docker run --rm -t \
|
||||||
-v "${LATEST_DIR}:/out" \
|
|
||||||
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
||||||
-e CLAWDBOT_INSTALL_LATEST_OUT="/out/latest" \
|
|
||||||
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
|
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
|
||||||
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
|
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
|
||||||
-e CLAWDBOT_NO_ONBOARD=1 \
|
-e CLAWDBOT_NO_ONBOARD=1 \
|
||||||
-e DEBIAN_FRONTEND=noninteractive \
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
"$SMOKE_IMAGE"
|
"$SMOKE_IMAGE"
|
||||||
|
|
||||||
LATEST_VERSION=""
|
echo "==> Build non-root image: $NONROOT_IMAGE"
|
||||||
if [[ -f "$LATEST_FILE" ]]; then
|
docker build \
|
||||||
LATEST_VERSION="$(cat "$LATEST_FILE")"
|
-t "$NONROOT_IMAGE" \
|
||||||
fi
|
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
|
||||||
|
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
|
||||||
|
|
||||||
if [[ "$SKIP_NONROOT" == "1" ]]; then
|
echo "==> Run installer non-root test: $INSTALL_URL"
|
||||||
echo "==> Skip non-root installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1)"
|
docker run --rm -t \
|
||||||
else
|
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
||||||
echo "==> Build non-root image: $NONROOT_IMAGE"
|
-e CLAWDBOT_NO_ONBOARD=1 \
|
||||||
docker build \
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
-t "$NONROOT_IMAGE" \
|
"$NONROOT_IMAGE"
|
||||||
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
|
|
||||||
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
|
|
||||||
|
|
||||||
echo "==> Run installer non-root test: $INSTALL_URL"
|
|
||||||
docker run --rm -t \
|
|
||||||
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
|
||||||
-e CLAWDBOT_INSTALL_EXPECT_VERSION="$LATEST_VERSION" \
|
|
||||||
-e CLAWDBOT_NO_ONBOARD=1 \
|
|
||||||
-e DEBIAN_FRONTEND=noninteractive \
|
|
||||||
"$NONROOT_IMAGE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}" == "1" ]]; then
|
if [[ "${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}" == "1" ]]; then
|
||||||
echo "==> Skip CLI installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_CLI=1)"
|
echo "==> Skip CLI installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_CLI=1)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$SKIP_NONROOT" == "1" ]]; then
|
|
||||||
echo "==> Skip CLI installer smoke (non-root image skipped)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Run CLI installer non-root test (same image)"
|
echo "==> Run CLI installer non-root test (same image)"
|
||||||
docker run --rm -t \
|
docker run --rm -t \
|
||||||
--entrypoint /bin/bash \
|
--entrypoint /bin/bash \
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { spawn } from "node:child_process";
|
|
||||||
import os from "node:os";
|
|
||||||
|
|
||||||
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
||||||
|
|
||||||
const runs = [
|
|
||||||
{
|
|
||||||
name: "unit",
|
|
||||||
args: ["vitest", "run", "--config", "vitest.unit.config.ts"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extensions",
|
|
||||||
args: ["vitest", "run", "--config", "vitest.extensions.config.ts"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gateway",
|
|
||||||
args: ["vitest", "run", "--config", "vitest.gateway.config.ts"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const parallelRuns = runs.filter((entry) => entry.name !== "gateway");
|
|
||||||
const serialRuns = runs.filter((entry) => entry.name === "gateway");
|
|
||||||
|
|
||||||
const children = new Set();
|
|
||||||
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
|
||||||
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
|
||||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
|
||||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
|
||||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
|
||||||
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
|
||||||
|
|
||||||
const run = (entry) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
|
|
||||||
const child = spawn(pnpm, args, {
|
|
||||||
stdio: "inherit",
|
|
||||||
env: { ...process.env, VITEST_GROUP: entry.name },
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
});
|
|
||||||
children.add(child);
|
|
||||||
child.on("exit", (code, signal) => {
|
|
||||||
children.delete(child);
|
|
||||||
resolve(code ?? (signal ? 1 : 0));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const shutdown = (signal) => {
|
|
||||||
for (const child of children) {
|
|
||||||
child.kill(signal);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
||||||
|
|
||||||
const parallelCodes = await Promise.all(parallelRuns.map(run));
|
|
||||||
const failedParallel = parallelCodes.find((code) => code !== 0);
|
|
||||||
if (failedParallel !== undefined) {
|
|
||||||
process.exit(failedParallel);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of serialRuns) {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const code = await run(entry);
|
|
||||||
if (code !== 0) {
|
|
||||||
process.exit(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
type AuthProfileStore,
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
resolveApiKeyForProfile,
|
||||||
|
} from "./auth-profiles.js";
|
||||||
|
|
||||||
|
vi.mock("@mariozechner/pi-ai", () => ({
|
||||||
|
getOAuthApiKey: vi.fn(() => {
|
||||||
|
throw new Error("refresh should not be called");
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("auth-profiles (github-copilot)", () => {
|
||||||
|
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||||
|
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||||
|
let tempDir: string | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
if (tempDir) {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
tempDir = null;
|
||||||
|
}
|
||||||
|
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||||
|
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||||
|
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||||
|
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||||
|
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats copilot oauth tokens with expires=0 as non-expiring", async () => {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-"));
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||||
|
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
||||||
|
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||||
|
|
||||||
|
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
|
||||||
|
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
||||||
|
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
"github-copilot:github": {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "github-copilot",
|
||||||
|
refresh: "gh-token",
|
||||||
|
access: "gh-token",
|
||||||
|
expires: 0,
|
||||||
|
enterpriseUrl: "company.ghe.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
|
||||||
|
|
||||||
|
const loaded = ensureAuthProfileStore();
|
||||||
|
const resolved = await resolveApiKeyForProfile({
|
||||||
|
store: loaded,
|
||||||
|
profileId: "github-copilot:github",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved?.apiKey).toBe("gh-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,15 @@ async function refreshOAuthTokenWithLock(params: {
|
|||||||
const cred = store.profiles[params.profileId];
|
const cred = store.profiles[params.profileId];
|
||||||
if (!cred || cred.type !== "oauth") return null;
|
if (!cred || cred.type !== "oauth") return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
cred.provider === "github-copilot" &&
|
||||||
|
(!Number.isFinite(cred.expires) || cred.expires <= 0)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
|
newCredentials: cred,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (Date.now() < cred.expires) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
@@ -103,6 +112,20 @@ async function tryResolveOAuthProfile(params: {
|
|||||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||||
|
|
||||||
|
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
||||||
|
return {
|
||||||
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
|
provider: cred.provider,
|
||||||
|
email: cred.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
||||||
|
return {
|
||||||
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
|
provider: cred.provider,
|
||||||
|
email: cred.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (Date.now() < cred.expires) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type TokenCredential = {
|
|||||||
token: string;
|
token: string;
|
||||||
/** Optional expiry timestamp (ms since epoch). */
|
/** Optional expiry timestamp (ms since epoch). */
|
||||||
expires?: number;
|
expires?: number;
|
||||||
|
enterpriseUrl?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
resetProcessRegistryForTests();
|
|
||||||
vi.resetModules();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exec falls back when PTY spawn fails", async () => {
|
|
||||||
vi.doMock("@lydell/node-pty", () => ({
|
|
||||||
spawn: () => {
|
|
||||||
const err = new Error("spawn EBADF");
|
|
||||||
(err as NodeJS.ErrnoException).code = "EBADF";
|
|
||||||
throw err;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createExecTool } = await import("./bash-tools.exec");
|
|
||||||
const tool = createExecTool({ allowBackground: false });
|
|
||||||
const result = await tool.execute("toolcall", {
|
|
||||||
command: "printf ok",
|
|
||||||
pty: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.details.status).toBe("completed");
|
|
||||||
const text = result.content?.[0]?.text ?? "";
|
|
||||||
expect(text).toContain("ok");
|
|
||||||
expect(text).toContain("PTY spawn failed");
|
|
||||||
});
|
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
resolveShellEnvFallbackTimeoutMs,
|
resolveShellEnvFallbackTimeoutMs,
|
||||||
} from "../infra/shell-env.js";
|
} from "../infra/shell-env.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { logInfo, logWarn } from "../logger.js";
|
import { logInfo } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
type ProcessSession,
|
type ProcessSession,
|
||||||
type SessionStdin,
|
type SessionStdin,
|
||||||
@@ -381,56 +381,41 @@ async function runExecProcess(opts: {
|
|||||||
) as ChildProcessWithoutNullStreams;
|
) as ChildProcessWithoutNullStreams;
|
||||||
stdin = child.stdin;
|
stdin = child.stdin;
|
||||||
} else if (opts.usePty) {
|
} else if (opts.usePty) {
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
|
||||||
try {
|
spawn?: PtySpawn;
|
||||||
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
|
default?: { spawn?: PtySpawn };
|
||||||
spawn?: PtySpawn;
|
};
|
||||||
default?: { spawn?: PtySpawn };
|
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
||||||
};
|
if (!spawnPty) {
|
||||||
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
||||||
if (!spawnPty) {
|
|
||||||
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
|
||||||
}
|
|
||||||
pty = spawnPty(shell, [...shellArgs, opts.command], {
|
|
||||||
cwd: opts.workdir,
|
|
||||||
env: opts.env,
|
|
||||||
name: process.env.TERM ?? "xterm-256color",
|
|
||||||
cols: 120,
|
|
||||||
rows: 30,
|
|
||||||
});
|
|
||||||
stdin = {
|
|
||||||
destroyed: false,
|
|
||||||
write: (data, cb) => {
|
|
||||||
try {
|
|
||||||
pty?.write(data);
|
|
||||||
cb?.(null);
|
|
||||||
} catch (err) {
|
|
||||||
cb?.(err as Error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
end: () => {
|
|
||||||
try {
|
|
||||||
const eof = process.platform === "win32" ? "\x1a" : "\x04";
|
|
||||||
pty?.write(eof);
|
|
||||||
} catch {
|
|
||||||
// ignore EOF errors
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const errText = String(err);
|
|
||||||
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
|
|
||||||
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
|
|
||||||
opts.warnings.push(warning);
|
|
||||||
child = spawn(shell, [...shellArgs, opts.command], {
|
|
||||||
cwd: opts.workdir,
|
|
||||||
env: opts.env,
|
|
||||||
detached: process.platform !== "win32",
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
windowsHide: true,
|
|
||||||
}) as ChildProcessWithoutNullStreams;
|
|
||||||
stdin = child.stdin;
|
|
||||||
}
|
}
|
||||||
|
const { shell, args: shellArgs } = getShellConfig();
|
||||||
|
pty = spawnPty(shell, [...shellArgs, opts.command], {
|
||||||
|
cwd: opts.workdir,
|
||||||
|
env: opts.env,
|
||||||
|
name: process.env.TERM ?? "xterm-256color",
|
||||||
|
cols: 120,
|
||||||
|
rows: 30,
|
||||||
|
});
|
||||||
|
stdin = {
|
||||||
|
destroyed: false,
|
||||||
|
write: (data, cb) => {
|
||||||
|
try {
|
||||||
|
pty?.write(data);
|
||||||
|
cb?.(null);
|
||||||
|
} catch (err) {
|
||||||
|
cb?.(err as Error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
end: () => {
|
||||||
|
try {
|
||||||
|
const eof = process.platform === "win32" ? "\x1a" : "\x04";
|
||||||
|
pty?.write(eof);
|
||||||
|
} catch {
|
||||||
|
// ignore EOF errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
const { shell, args: shellArgs } = getShellConfig();
|
||||||
child = spawn(shell, [...shellArgs, opts.command], {
|
child = spawn(shell, [...shellArgs, opts.command], {
|
||||||
@@ -1335,7 +1320,7 @@ export function createExecTool(
|
|||||||
|
|
||||||
const effectiveTimeout =
|
const effectiveTimeout =
|
||||||
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
|
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
|
||||||
const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : "");
|
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
|
||||||
const usePty = params.pty === true && !sandbox;
|
const usePty = params.pty === true && !sandbox;
|
||||||
const run = await runExecProcess({
|
const run = await runExecProcess({
|
||||||
command: params.command,
|
command: params.command,
|
||||||
@@ -1375,7 +1360,7 @@ export function createExecTool(
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text:
|
text:
|
||||||
`${getWarningText()}` +
|
`${warningText}` +
|
||||||
`Command still running (session ${run.session.id}, pid ${
|
`Command still running (session ${run.session.id}, pid ${
|
||||||
run.session.pid ?? "n/a"
|
run.session.pid ?? "n/a"
|
||||||
}). ` +
|
}). ` +
|
||||||
@@ -1425,7 +1410,7 @@ export function createExecTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `${getWarningText()}${outcome.aggregated || "(no output)"}`,
|
text: `${warningText}${outcome.aggregated || "(no output)"}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
|
|
||||||
vi.mock("./tools/gateway.js", () => ({
|
vi.mock("./tools/gateway.js", () => ({
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
|
|
||||||
describe("agents_list", () => {
|
describe("agents_list", () => {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ vi.mock("../media/image-ops.js", () => ({
|
|||||||
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
|
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
|
|
||||||
describe("nodes camera_snap", () => {
|
describe("nodes camera_snap", () => {
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ vi.mock("../infra/provider-usage.js", () => ({
|
|||||||
formatUsageSummaryLine: () => null,
|
formatUsageSummaryLine: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
|
|
||||||
describe("session_status tool", () => {
|
describe("session_status tool", () => {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const callGatewayMock = vi.fn();
|
|||||||
vi.mock("../gateway/call.js", () => ({
|
vi.mock("../gateway/call.js", () => ({
|
||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../plugins/tools.js", () => ({
|
||||||
|
resolvePluginTools: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
@@ -20,7 +23,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
|
|
||||||
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
|
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
|
||||||
|
|||||||
-1
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
|
|||||||
-1
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const callGatewayMock = vi.fn();
|
||||||
|
vi.mock("../gateway/call.js", () => ({
|
||||||
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||||
|
session: {
|
||||||
|
mainKey: "main",
|
||||||
|
scope: "per-sender",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadConfig: () => configOverride,
|
||||||
|
resolveGatewayPort: () => 18789,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
|
describe("clawdbot-tools: subagents", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configOverride = {
|
||||||
|
session: {
|
||||||
|
mainKey: "main",
|
||||||
|
scope: "per-sender",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sessions_spawn runs cleanup via lifecycle events", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
|
let agentCallCount = 0;
|
||||||
|
let deletedKey: string | undefined;
|
||||||
|
let childRunId: string | undefined;
|
||||||
|
let childSessionKey: string | undefined;
|
||||||
|
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
||||||
|
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const request = opts as { method?: string; params?: unknown };
|
||||||
|
calls.push(request);
|
||||||
|
if (request.method === "agent") {
|
||||||
|
agentCallCount += 1;
|
||||||
|
const runId = `run-${agentCallCount}`;
|
||||||
|
const params = request.params as {
|
||||||
|
message?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
channel?: string;
|
||||||
|
timeout?: number;
|
||||||
|
lane?: string;
|
||||||
|
};
|
||||||
|
// Only capture the first agent call (subagent spawn, not main agent trigger)
|
||||||
|
if (params?.lane === "subagent") {
|
||||||
|
childRunId = runId;
|
||||||
|
childSessionKey = params?.sessionKey ?? "";
|
||||||
|
expect(params?.channel).toBe("discord");
|
||||||
|
expect(params?.timeout).toBe(1);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
status: "accepted",
|
||||||
|
acceptedAt: 1000 + agentCallCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.method === "agent.wait") {
|
||||||
|
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||||
|
waitCalls.push(params ?? {});
|
||||||
|
// Return "ok" with timing info for the child run
|
||||||
|
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||||
|
}
|
||||||
|
if (request.method === "sessions.delete") {
|
||||||
|
const params = request.params as { key?: string } | undefined;
|
||||||
|
deletedKey = params?.key;
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createClawdbotTools({
|
||||||
|
agentSessionKey: "discord:group:req",
|
||||||
|
agentChannel: "discord",
|
||||||
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
|
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||||
|
|
||||||
|
const result = await tool.execute("call1", {
|
||||||
|
task: "do thing",
|
||||||
|
runTimeoutSeconds: 1,
|
||||||
|
cleanup: "delete",
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childRunId) throw new Error("missing child runId");
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: childRunId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "end",
|
||||||
|
startedAt: 1234,
|
||||||
|
endedAt: 2345,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
||||||
|
expect(childWait?.timeoutMs).toBe(1000);
|
||||||
|
|
||||||
|
// Two agent calls: subagent spawn + main agent trigger
|
||||||
|
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||||
|
expect(agentCalls).toHaveLength(2);
|
||||||
|
|
||||||
|
// First call: subagent spawn
|
||||||
|
const first = agentCalls[0]?.params as
|
||||||
|
| {
|
||||||
|
lane?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
sessionKey?: string;
|
||||||
|
channel?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
expect(first?.lane).toBe("subagent");
|
||||||
|
expect(first?.deliver).toBe(false);
|
||||||
|
expect(first?.channel).toBe("discord");
|
||||||
|
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
|
||||||
|
// Second call: main agent trigger with announce message
|
||||||
|
const second = agentCalls[1]?.params as
|
||||||
|
| {
|
||||||
|
sessionKey?: string;
|
||||||
|
message?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
expect(second?.sessionKey).toBe("discord:group:req");
|
||||||
|
expect(second?.deliver).toBe(true);
|
||||||
|
expect(second?.message).toContain("background task");
|
||||||
|
|
||||||
|
// No direct send to external channel (main agent handles delivery)
|
||||||
|
const sendCalls = calls.filter((c) => c.method === "send");
|
||||||
|
expect(sendCalls.length).toBe(0);
|
||||||
|
|
||||||
|
// Session should be deleted since cleanup=delete
|
||||||
|
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sessions_spawn announces with requester accountId", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
|
let agentCallCount = 0;
|
||||||
|
let childRunId: string | undefined;
|
||||||
|
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const request = opts as { method?: string; params?: unknown };
|
||||||
|
calls.push(request);
|
||||||
|
if (request.method === "agent") {
|
||||||
|
agentCallCount += 1;
|
||||||
|
const runId = `run-${agentCallCount}`;
|
||||||
|
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
|
||||||
|
if (params?.lane === "subagent") {
|
||||||
|
childRunId = runId;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
status: "accepted",
|
||||||
|
acceptedAt: 4000 + agentCallCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.method === "agent.wait") {
|
||||||
|
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||||
|
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||||
|
}
|
||||||
|
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createClawdbotTools({
|
||||||
|
agentSessionKey: "main",
|
||||||
|
agentChannel: "whatsapp",
|
||||||
|
agentAccountId: "kev",
|
||||||
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
|
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||||
|
|
||||||
|
const result = await tool.execute("call2", {
|
||||||
|
task: "do thing",
|
||||||
|
runTimeoutSeconds: 1,
|
||||||
|
cleanup: "keep",
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childRunId) throw new Error("missing child runId");
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: childRunId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "end",
|
||||||
|
startedAt: 1000,
|
||||||
|
endedAt: 2000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||||
|
expect(agentCalls).toHaveLength(2);
|
||||||
|
const announceParams = agentCalls[1]?.params as
|
||||||
|
| { accountId?: string; channel?: string; deliver?: boolean }
|
||||||
|
| undefined;
|
||||||
|
expect(announceParams?.deliver).toBe(true);
|
||||||
|
expect(announceParams?.channel).toBe("whatsapp");
|
||||||
|
expect(announceParams?.accountId).toBe("kev");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
|
|||||||
-193
@@ -21,8 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
@@ -122,195 +120,4 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
});
|
});
|
||||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sessions_spawn runs cleanup via lifecycle events", async () => {
|
|
||||||
resetSubagentRegistryForTests();
|
|
||||||
callGatewayMock.mockReset();
|
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
|
||||||
let agentCallCount = 0;
|
|
||||||
let deletedKey: string | undefined;
|
|
||||||
let childRunId: string | undefined;
|
|
||||||
let childSessionKey: string | undefined;
|
|
||||||
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
|
||||||
const request = opts as { method?: string; params?: unknown };
|
|
||||||
calls.push(request);
|
|
||||||
if (request.method === "agent") {
|
|
||||||
agentCallCount += 1;
|
|
||||||
const runId = `run-${agentCallCount}`;
|
|
||||||
const params = request.params as {
|
|
||||||
message?: string;
|
|
||||||
sessionKey?: string;
|
|
||||||
channel?: string;
|
|
||||||
timeout?: number;
|
|
||||||
lane?: string;
|
|
||||||
};
|
|
||||||
if (params?.lane === "subagent") {
|
|
||||||
childRunId = runId;
|
|
||||||
childSessionKey = params?.sessionKey ?? "";
|
|
||||||
expect(params?.channel).toBe("discord");
|
|
||||||
expect(params?.timeout).toBe(1);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
runId,
|
|
||||||
status: "accepted",
|
|
||||||
acceptedAt: 1000 + agentCallCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (request.method === "agent.wait") {
|
|
||||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
|
||||||
waitCalls.push(params ?? {});
|
|
||||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
|
||||||
}
|
|
||||||
if (request.method === "sessions.delete") {
|
|
||||||
const params = request.params as { key?: string } | undefined;
|
|
||||||
deletedKey = params?.key;
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const tool = createClawdbotTools({
|
|
||||||
agentSessionKey: "discord:group:req",
|
|
||||||
agentChannel: "discord",
|
|
||||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
|
||||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
|
||||||
|
|
||||||
const result = await tool.execute("call1", {
|
|
||||||
task: "do thing",
|
|
||||||
runTimeoutSeconds: 1,
|
|
||||||
cleanup: "delete",
|
|
||||||
});
|
|
||||||
expect(result.details).toMatchObject({
|
|
||||||
status: "accepted",
|
|
||||||
runId: "run-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!childRunId) throw new Error("missing child runId");
|
|
||||||
emitAgentEvent({
|
|
||||||
runId: childRunId,
|
|
||||||
stream: "lifecycle",
|
|
||||||
data: {
|
|
||||||
phase: "end",
|
|
||||||
startedAt: 1234,
|
|
||||||
endedAt: 2345,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
|
||||||
expect(childWait?.timeoutMs).toBe(1000);
|
|
||||||
|
|
||||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
|
||||||
expect(agentCalls).toHaveLength(2);
|
|
||||||
|
|
||||||
const first = agentCalls[0]?.params as
|
|
||||||
| {
|
|
||||||
lane?: string;
|
|
||||||
deliver?: boolean;
|
|
||||||
sessionKey?: string;
|
|
||||||
channel?: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
expect(first?.lane).toBe("subagent");
|
|
||||||
expect(first?.deliver).toBe(false);
|
|
||||||
expect(first?.channel).toBe("discord");
|
|
||||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
|
||||||
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
|
||||||
|
|
||||||
const second = agentCalls[1]?.params as
|
|
||||||
| {
|
|
||||||
sessionKey?: string;
|
|
||||||
message?: string;
|
|
||||||
deliver?: boolean;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
expect(second?.sessionKey).toBe("discord:group:req");
|
|
||||||
expect(second?.deliver).toBe(true);
|
|
||||||
expect(second?.message).toContain("background task");
|
|
||||||
|
|
||||||
const sendCalls = calls.filter((c) => c.method === "send");
|
|
||||||
expect(sendCalls.length).toBe(0);
|
|
||||||
|
|
||||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sessions_spawn announces with requester accountId", async () => {
|
|
||||||
resetSubagentRegistryForTests();
|
|
||||||
callGatewayMock.mockReset();
|
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
|
||||||
let agentCallCount = 0;
|
|
||||||
let childRunId: string | undefined;
|
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
|
||||||
const request = opts as { method?: string; params?: unknown };
|
|
||||||
calls.push(request);
|
|
||||||
if (request.method === "agent") {
|
|
||||||
agentCallCount += 1;
|
|
||||||
const runId = `run-${agentCallCount}`;
|
|
||||||
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
|
|
||||||
if (params?.lane === "subagent") {
|
|
||||||
childRunId = runId;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
runId,
|
|
||||||
status: "accepted",
|
|
||||||
acceptedAt: 4000 + agentCallCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (request.method === "agent.wait") {
|
|
||||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
|
||||||
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
|
||||||
}
|
|
||||||
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const tool = createClawdbotTools({
|
|
||||||
agentSessionKey: "main",
|
|
||||||
agentChannel: "whatsapp",
|
|
||||||
agentAccountId: "kev",
|
|
||||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
|
||||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
|
||||||
|
|
||||||
const result = await tool.execute("call2", {
|
|
||||||
task: "do thing",
|
|
||||||
runTimeoutSeconds: 1,
|
|
||||||
cleanup: "keep",
|
|
||||||
});
|
|
||||||
expect(result.details).toMatchObject({
|
|
||||||
status: "accepted",
|
|
||||||
runId: "run-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!childRunId) throw new Error("missing child runId");
|
|
||||||
emitAgentEvent({
|
|
||||||
runId: childRunId,
|
|
||||||
stream: "lifecycle",
|
|
||||||
data: {
|
|
||||||
phase: "end",
|
|
||||||
startedAt: 1000,
|
|
||||||
endedAt: 2000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
|
||||||
expect(agentCalls).toHaveLength(2);
|
|
||||||
const announceParams = agentCalls[1]?.params as
|
|
||||||
| { accountId?: string; channel?: string; deliver?: boolean }
|
|
||||||
| undefined;
|
|
||||||
expect(announceParams?.deliver).toBe(true);
|
|
||||||
expect(announceParams?.channel).toBe("whatsapp");
|
|
||||||
expect(announceParams?.accountId).toBe("kev");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
-1
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
|
|||||||
-1
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import "./test-helpers/fast-core-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ export function isModernModelRef(ref: ModelRef): boolean {
|
|||||||
return matchesPrefix(id, XAI_PREFIXES);
|
return matchesPrefix(id, XAI_PREFIXES);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "opencode" && id.endsWith("-free")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "openrouter" || provider === "opencode") {
|
if (provider === "openrouter" || provider === "opencode") {
|
||||||
return matchesAny(id, [
|
return matchesAny(id, [
|
||||||
...ANTHROPIC_PREFIXES,
|
...ANTHROPIC_PREFIXES,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -51,16 +52,6 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
|
||||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
|
||||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
|
||||||
token: "copilot",
|
|
||||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
||||||
source: "mock",
|
|
||||||
baseUrl: "https://api.copilot.example",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
const agentDir = path.join(home, "agent-default-base-url");
|
const agentDir = path.join(home, "agent-default-base-url");
|
||||||
@@ -71,48 +62,55 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||||
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
|
it("uses enterprise URL from auth profiles to derive base URL", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
|
||||||
const previousGh = process.env.GH_TOKEN;
|
|
||||||
const previousGithub = process.env.GITHUB_TOKEN;
|
|
||||||
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
|
|
||||||
process.env.GH_TOKEN = "gh-token";
|
|
||||||
process.env.GITHUB_TOKEN = "github-token";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
|
||||||
token: "copilot",
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
await fs.writeFile(
|
||||||
source: "mock",
|
path.join(agentDir, "auth-profiles.json"),
|
||||||
baseUrl: "https://api.copilot.example",
|
JSON.stringify(
|
||||||
});
|
{
|
||||||
|
version: 1,
|
||||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
profiles: {
|
||||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
"github-copilot:github": {
|
||||||
resolveCopilotApiToken,
|
type: "oauth",
|
||||||
}));
|
provider: "github-copilot",
|
||||||
|
refresh: "gh-token",
|
||||||
|
access: "gh-token",
|
||||||
|
expires: 0,
|
||||||
|
enterpriseUrl: "company.ghe.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } });
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
|
|
||||||
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||||
expect.objectContaining({ githubToken: "copilot-token" }),
|
const parsed = JSON.parse(raw) as {
|
||||||
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||||
|
"https://copilot-api.company.ghe.com",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
// no-op
|
||||||
process.env.GH_TOKEN = previousGh;
|
|
||||||
process.env.GITHUB_TOKEN = previousGithub;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -43,7 +44,7 @@ describe("models-config", () => {
|
|||||||
process.env.HOME = previousHome;
|
process.env.HOME = previousHome;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to default baseUrl when token exchange fails", async () => {
|
it("uses default baseUrl when env token is present", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||||
@@ -51,11 +52,6 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
|
||||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
|
|
||||||
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
@@ -67,13 +63,13 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string }>;
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("uses agentDir override auth profiles for copilot injection", async () => {
|
it("normalizes enterprise URL when deriving base URL", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
const previousGh = process.env.GH_TOKEN;
|
const previousGh = process.env.GH_TOKEN;
|
||||||
@@ -94,9 +90,12 @@ describe("models-config", () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
profiles: {
|
profiles: {
|
||||||
"github-copilot:github": {
|
"github-copilot:github": {
|
||||||
type: "token",
|
type: "oauth",
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
token: "gh-profile-token",
|
refresh: "gh-profile-token",
|
||||||
|
access: "gh-profile-token",
|
||||||
|
expires: 0,
|
||||||
|
enterpriseUrl: "https://company.ghe.com/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -105,16 +104,6 @@ describe("models-config", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
|
||||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
|
||||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
|
||||||
token: "copilot",
|
|
||||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
||||||
source: "mock",
|
|
||||||
baseUrl: "https://api.copilot.example",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
@@ -124,7 +113,9 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string }>;
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||||
|
"https://copilot-api.company.ghe.com",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_COPILOT_API_BASE_URL,
|
normalizeGithubCopilotDomain,
|
||||||
resolveCopilotApiToken,
|
resolveGithubCopilotBaseUrl,
|
||||||
} from "../providers/github-copilot-token.js";
|
} from "../providers/github-copilot-utils.js";
|
||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -331,29 +331,18 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
|
|
||||||
if (!hasProfile && !githubToken) return null;
|
if (!hasProfile && !githubToken) return null;
|
||||||
|
|
||||||
let selectedGithubToken = githubToken;
|
let enterpriseDomain: string | null = null;
|
||||||
if (!selectedGithubToken && hasProfile) {
|
if (hasProfile) {
|
||||||
// Use the first available profile as a default for discovery (it will be
|
// Use the first available profile as a default for discovery (it will be
|
||||||
// re-resolved per-run by the embedded runner).
|
// re-resolved per-run by the embedded runner).
|
||||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||||
if (profile && profile.type === "token") {
|
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
|
||||||
selectedGithubToken = profile.token;
|
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain);
|
||||||
if (selectedGithubToken) {
|
|
||||||
try {
|
|
||||||
const token = await resolveCopilotApiToken({
|
|
||||||
githubToken: selectedGithubToken,
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
baseUrl = token.baseUrl;
|
|
||||||
} catch {
|
|
||||||
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||||
@@ -364,7 +353,7 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||||
// patterns for OAuth-like providers in pi-coding-agent.
|
// patterns for OAuth-like providers in pi-coding-agent.
|
||||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||||
// Clawdbot uses its own auth store and exchanges tokens at runtime.
|
// Clawdbot uses its own auth store and passes the GitHub token at runtime.
|
||||||
// `models list` uses Clawdbot's auth heuristics for availability.
|
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||||
|
|
||||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -80,25 +81,16 @@ describe("models-config", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
|
||||||
token: "copilot",
|
|
||||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
||||||
source: "mock",
|
|
||||||
baseUrl: "https://api.copilot.example",
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
|
||||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
|
||||||
resolveCopilotApiToken,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
|
|
||||||
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||||
expect.objectContaining({ githubToken: "alpha-token" }),
|
const parsed = JSON.parse(raw) as {
|
||||||
);
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
@@ -117,16 +109,6 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
|
||||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
|
||||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
|
||||||
token: "copilot",
|
|
||||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
||||||
source: "mock",
|
|
||||||
baseUrl: "https://api.copilot.example",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ describe("resolveOpencodeZenAlias", () => {
|
|||||||
describe("resolveOpencodeZenModelApi", () => {
|
describe("resolveOpencodeZenModelApi", () => {
|
||||||
it("maps APIs by model family", () => {
|
it("maps APIs by model family", () => {
|
||||||
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
|
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
|
||||||
|
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
|
||||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
||||||
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
||||||
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("glm-4.7")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -54,10 +55,10 @@ describe("getOpencodeZenStaticFallbackModels", () => {
|
|||||||
it("returns an array of models", () => {
|
it("returns an array of models", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
const models = getOpencodeZenStaticFallbackModels();
|
||||||
expect(Array.isArray(models)).toBe(true);
|
expect(Array.isArray(models)).toBe(true);
|
||||||
expect(models.length).toBe(10);
|
expect(models.length).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes Claude, GPT, Gemini, and GLM models", () => {
|
it("includes Claude, GPT, Gemini, GLM, and MiniMax models", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
const models = getOpencodeZenStaticFallbackModels();
|
||||||
const ids = models.map((m) => m.id);
|
const ids = models.map((m) => m.id);
|
||||||
|
|
||||||
@@ -65,7 +66,8 @@ describe("getOpencodeZenStaticFallbackModels", () => {
|
|||||||
expect(ids).toContain("gpt-5.2");
|
expect(ids).toContain("gpt-5.2");
|
||||||
expect(ids).toContain("gpt-5.1-codex");
|
expect(ids).toContain("gpt-5.1-codex");
|
||||||
expect(ids).toContain("gemini-3-pro");
|
expect(ids).toContain("gemini-3-pro");
|
||||||
expect(ids).toContain("glm-4.7");
|
expect(ids).toContain("glm-4.7-free");
|
||||||
|
expect(ids).toContain("minimax-m2.1-free");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns valid ModelDefinitionConfig objects", () => {
|
it("returns valid ModelDefinitionConfig objects", () => {
|
||||||
@@ -88,7 +90,8 @@ describe("OPENCODE_ZEN_MODEL_ALIASES", () => {
|
|||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.codex).toBe("gpt-5.1-codex");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.codex).toBe("gpt-5.1-codex");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7-free");
|
||||||
|
expect(OPENCODE_ZEN_MODEL_ALIASES.minimax).toBe("minimax-m2.1-free");
|
||||||
|
|
||||||
// Legacy aliases (kept for backward compatibility).
|
// Legacy aliases (kept for backward compatibility).
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-opus-4-5");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-opus-4-5");
|
||||||
|
|||||||
@@ -68,9 +68,13 @@ export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
|
|||||||
"gemini-2.5-flash": "gemini-3-flash",
|
"gemini-2.5-flash": "gemini-3-flash",
|
||||||
|
|
||||||
// GLM (free + alpha)
|
// GLM (free + alpha)
|
||||||
glm: "glm-4.7",
|
glm: "glm-4.7-free",
|
||||||
"glm-free": "glm-4.7",
|
"glm-free": "glm-4.7-free",
|
||||||
"alpha-glm": "alpha-glm-4.7",
|
"alpha-glm": "alpha-glm-4.7",
|
||||||
|
|
||||||
|
// MiniMax
|
||||||
|
minimax: "minimax-m2.1-free",
|
||||||
|
"minimax-free": "minimax-m2.1-free",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +134,7 @@ const MODEL_COSTS: Record<
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
||||||
"glm-4.7": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
"glm-4.7-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
|
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
|
||||||
"gpt-5.1-codex-max": {
|
"gpt-5.1-codex-max": {
|
||||||
input: 1.25,
|
input: 1.25,
|
||||||
@@ -138,6 +142,7 @@ const MODEL_COSTS: Record<
|
|||||||
cacheRead: 0.125,
|
cacheRead: 0.125,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
|
"minimax-m2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,9 +155,10 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|||||||
"alpha-glm-4.7": 204800,
|
"alpha-glm-4.7": 204800,
|
||||||
"gpt-5.1-codex-mini": 400000,
|
"gpt-5.1-codex-mini": 400000,
|
||||||
"gpt-5.1": 400000,
|
"gpt-5.1": 400000,
|
||||||
"glm-4.7": 204800,
|
"glm-4.7-free": 204800,
|
||||||
"gemini-3-flash": 1048576,
|
"gemini-3-flash": 1048576,
|
||||||
"gpt-5.1-codex-max": 400000,
|
"gpt-5.1-codex-max": 400000,
|
||||||
|
"minimax-m2.1-free": 204800,
|
||||||
"gpt-5.2": 400000,
|
"gpt-5.2": 400000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,9 +173,10 @@ const MODEL_MAX_TOKENS: Record<string, number> = {
|
|||||||
"alpha-glm-4.7": 131072,
|
"alpha-glm-4.7": 131072,
|
||||||
"gpt-5.1-codex-mini": 128000,
|
"gpt-5.1-codex-mini": 128000,
|
||||||
"gpt-5.1": 128000,
|
"gpt-5.1": 128000,
|
||||||
"glm-4.7": 131072,
|
"glm-4.7-free": 131072,
|
||||||
"gemini-3-flash": 65536,
|
"gemini-3-flash": 65536,
|
||||||
"gpt-5.1-codex-max": 128000,
|
"gpt-5.1-codex-max": 128000,
|
||||||
|
"minimax-m2.1-free": 131072,
|
||||||
"gpt-5.2": 128000,
|
"gpt-5.2": 128000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,9 +211,10 @@ const MODEL_NAMES: Record<string, string> = {
|
|||||||
"alpha-glm-4.7": "Alpha GLM-4.7",
|
"alpha-glm-4.7": "Alpha GLM-4.7",
|
||||||
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
||||||
"gpt-5.1": "GPT-5.1",
|
"gpt-5.1": "GPT-5.1",
|
||||||
"glm-4.7": "GLM-4.7",
|
"glm-4.7-free": "GLM-4.7",
|
||||||
"gemini-3-flash": "Gemini 3 Flash",
|
"gemini-3-flash": "Gemini 3 Flash",
|
||||||
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
||||||
|
"minimax-m2.1-free": "MiniMax M2.1",
|
||||||
"gpt-5.2": "GPT-5.2",
|
"gpt-5.2": "GPT-5.2",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,9 +240,10 @@ export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
|
|||||||
"alpha-glm-4.7",
|
"alpha-glm-4.7",
|
||||||
"gpt-5.1-codex-mini",
|
"gpt-5.1-codex-mini",
|
||||||
"gpt-5.1",
|
"gpt-5.1",
|
||||||
"glm-4.7",
|
"glm-4.7-free",
|
||||||
"gemini-3-flash",
|
"gemini-3-flash",
|
||||||
"gpt-5.1-codex-max",
|
"gpt-5.1-codex-max",
|
||||||
|
"minimax-m2.1-free",
|
||||||
"gpt-5.2",
|
"gpt-5.2",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
|
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
|
||||||
@@ -16,13 +16,11 @@ vi.mock("./pi-embedded-runner/run/attempt.js", () => ({
|
|||||||
|
|
||||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeEach(async () => {
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.resetModules();
|
||||||
runEmbeddedAttemptMock.mockReset();
|
runEmbeddedAttemptMock.mockReset();
|
||||||
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseUsage = {
|
const baseUsage = {
|
||||||
|
|||||||
@@ -128,13 +128,6 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (model.provider === "github-copilot") {
|
|
||||||
const { resolveCopilotApiToken } =
|
|
||||||
await import("../../providers/github-copilot-token.js");
|
|
||||||
const copilotToken = await resolveCopilotApiToken({
|
|
||||||
githubToken: apiKeyInfo.apiKey,
|
|
||||||
});
|
|
||||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
|
||||||
} else {
|
} else {
|
||||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { EventEmitter } from "node:events";
|
|
||||||
|
|
||||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import type { TSchema } from "@sinclair/typebox";
|
import type { TSchema } from "@sinclair/typebox";
|
||||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
@@ -186,28 +184,10 @@ export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event emitter for unhandled compaction failures that escape try-catch blocks.
|
|
||||||
// Listeners can use this to trigger session recovery with retry.
|
|
||||||
const compactionFailureEmitter = new EventEmitter();
|
|
||||||
|
|
||||||
export type CompactionFailureListener = (reason: string) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a listener for unhandled compaction failures.
|
|
||||||
* Called when auto-compaction fails in a way that escapes the normal try-catch,
|
|
||||||
* e.g., when the summarization request itself exceeds the model's token limit.
|
|
||||||
* Returns an unsubscribe function.
|
|
||||||
*/
|
|
||||||
export function onUnhandledCompactionFailure(cb: CompactionFailureListener): () => void {
|
|
||||||
compactionFailureEmitter.on("failure", cb);
|
|
||||||
return () => compactionFailureEmitter.off("failure", cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerUnhandledRejectionHandler((reason) => {
|
registerUnhandledRejectionHandler((reason) => {
|
||||||
const message = describeUnknownError(reason);
|
const message = describeUnknownError(reason);
|
||||||
if (!isCompactionFailureError(message)) return false;
|
if (!isCompactionFailureError(message)) return false;
|
||||||
log.error(`Auto-compaction failed (unhandled): ${message}`);
|
log.error(`Auto-compaction failed (unhandled): ${message}`);
|
||||||
compactionFailureEmitter.emit("failure", message);
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,20 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
|||||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||||
import { normalizeModelCompat } from "../model-compat.js";
|
import { normalizeModelCompat } from "../model-compat.js";
|
||||||
import { normalizeProviderId } from "../model-selection.js";
|
import { normalizeProviderId } from "../model-selection.js";
|
||||||
|
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
|
||||||
|
|
||||||
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
||||||
|
|
||||||
|
function applyProviderModelOverrides(model: Model<Api>): Model<Api> {
|
||||||
|
if (model.provider === "github-copilot") {
|
||||||
|
const headers = model.headers
|
||||||
|
? { ...model.headers, "User-Agent": resolveGithubCopilotUserAgent() }
|
||||||
|
: { "User-Agent": resolveGithubCopilotUserAgent() };
|
||||||
|
return { ...model, headers };
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildInlineProviderModels(
|
export function buildInlineProviderModels(
|
||||||
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
||||||
): InlineModelEntry[] {
|
): InlineModelEntry[] {
|
||||||
@@ -60,7 +71,7 @@ export function resolveModel(
|
|||||||
if (inlineMatch) {
|
if (inlineMatch) {
|
||||||
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
||||||
return {
|
return {
|
||||||
model: normalized,
|
model: applyProviderModelOverrides(normalized),
|
||||||
authStorage,
|
authStorage,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
};
|
};
|
||||||
@@ -78,7 +89,7 @@ export function resolveModel(
|
|||||||
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
||||||
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
||||||
} as Model<Api>);
|
} as Model<Api>);
|
||||||
return { model: fallbackModel, authStorage, modelRegistry };
|
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
error: `Unknown model: ${provider}/${modelId}`,
|
error: `Unknown model: ${provider}/${modelId}`,
|
||||||
@@ -86,5 +97,9 @@ export function resolveModel(
|
|||||||
modelRegistry,
|
modelRegistry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
|
return {
|
||||||
|
model: applyProviderModelOverrides(normalizeModelCompat(model)),
|
||||||
|
authStorage,
|
||||||
|
modelRegistry,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -184,17 +184,8 @@ export async function runEmbeddedPiAgent(
|
|||||||
lastProfileId = resolvedProfileId;
|
lastProfileId = resolvedProfileId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (model.provider === "github-copilot") {
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||||
const { resolveCopilotApiToken } =
|
lastProfileId = resolvedProfileId;
|
||||||
await import("../../providers/github-copilot-token.js");
|
|
||||||
const copilotToken = await resolveCopilotApiToken({
|
|
||||||
githubToken: apiKeyInfo.apiKey,
|
|
||||||
});
|
|
||||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
|
||||||
} else {
|
|
||||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
|
||||||
}
|
|
||||||
lastProfileId = apiKeyInfo.profileId;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const advanceAuthProfile = async (): Promise<boolean> => {
|
const advanceAuthProfile = async (): Promise<boolean> => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
|
|||||||
* - <invoke name="...">...</invoke> blocks
|
* - <invoke name="...">...</invoke> blocks
|
||||||
* - </minimax:tool_call> closing tags
|
* - </minimax:tool_call> closing tags
|
||||||
*/
|
*/
|
||||||
export function stripMinimaxToolCallXml(text: string): string {
|
function stripMinimaxToolCallXml(text: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
if (!/minimax:tool_call/i.test(text)) return text;
|
if (!/minimax:tool_call/i.test(text)) return text;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export function stripMinimaxToolCallXml(text: string): string {
|
|||||||
* downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should
|
* downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should
|
||||||
* not be shown to users.
|
* not be shown to users.
|
||||||
*/
|
*/
|
||||||
export function stripDowngradedToolCallText(text: string): string {
|
function stripDowngradedToolCallText(text: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
if (!/\[Tool (?:Call|Result)/i.test(text)) return text;
|
if (!/\[Tool (?:Call|Result)/i.test(text)) return text;
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ export function stripDowngradedToolCallText(text: string): string {
|
|||||||
* This is a safety net for cases where the model outputs <think> tags
|
* This is a safety net for cases where the model outputs <think> tags
|
||||||
* that slip through other filtering mechanisms.
|
* that slip through other filtering mechanisms.
|
||||||
*/
|
*/
|
||||||
export function stripThinkingTagsFromText(text: string): string {
|
function stripThinkingTagsFromText(text: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
// Quick check to avoid regex overhead when no tags present.
|
// Quick check to avoid regex overhead when no tags present.
|
||||||
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
|
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
|
||||||
|
|||||||
@@ -3,15 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { __testing } from "./compaction-safeguard.js";
|
import { __testing } from "./compaction-safeguard.js";
|
||||||
|
|
||||||
const {
|
const { collectToolFailures, formatToolFailuresSection } = __testing;
|
||||||
collectToolFailures,
|
|
||||||
formatToolFailuresSection,
|
|
||||||
computeAdaptiveChunkRatio,
|
|
||||||
isOversizedForSummary,
|
|
||||||
BASE_CHUNK_RATIO,
|
|
||||||
MIN_CHUNK_RATIO,
|
|
||||||
SAFETY_MARGIN,
|
|
||||||
} = __testing;
|
|
||||||
|
|
||||||
describe("compaction-safeguard tool failures", () => {
|
describe("compaction-safeguard tool failures", () => {
|
||||||
it("formats tool failures with meta and summary", () => {
|
it("formats tool failures with meta and summary", () => {
|
||||||
@@ -104,107 +96,3 @@ describe("compaction-safeguard tool failures", () => {
|
|||||||
expect(section).toBe("");
|
expect(section).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeAdaptiveChunkRatio", () => {
|
|
||||||
const CONTEXT_WINDOW = 200_000;
|
|
||||||
|
|
||||||
it("returns BASE_CHUNK_RATIO for normal messages", () => {
|
|
||||||
// Small messages: 1000 tokens each, well under 10% of context
|
|
||||||
const messages: AgentMessage[] = [
|
|
||||||
{ role: "user", content: "x".repeat(1000), timestamp: Date.now() },
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "y".repeat(1000) }],
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
|
||||||
expect(ratio).toBe(BASE_CHUNK_RATIO);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reduces ratio when average message > 10% of context", () => {
|
|
||||||
// Large messages: ~50K tokens each (25% of context)
|
|
||||||
const messages: AgentMessage[] = [
|
|
||||||
{ role: "user", content: "x".repeat(50_000 * 4), timestamp: Date.now() },
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "y".repeat(50_000 * 4) }],
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
|
||||||
expect(ratio).toBeLessThan(BASE_CHUNK_RATIO);
|
|
||||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects MIN_CHUNK_RATIO floor", () => {
|
|
||||||
// Very large messages that would push ratio below minimum
|
|
||||||
const messages: AgentMessage[] = [
|
|
||||||
{ role: "user", content: "x".repeat(150_000 * 4), timestamp: Date.now() },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
|
||||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles empty message array", () => {
|
|
||||||
const ratio = computeAdaptiveChunkRatio([], CONTEXT_WINDOW);
|
|
||||||
expect(ratio).toBe(BASE_CHUNK_RATIO);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles single huge message", () => {
|
|
||||||
// Single massive message
|
|
||||||
const messages: AgentMessage[] = [
|
|
||||||
{ role: "user", content: "x".repeat(180_000 * 4), timestamp: Date.now() },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
|
||||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
|
||||||
expect(ratio).toBeLessThanOrEqual(BASE_CHUNK_RATIO);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isOversizedForSummary", () => {
|
|
||||||
const CONTEXT_WINDOW = 200_000;
|
|
||||||
|
|
||||||
it("returns false for small messages", () => {
|
|
||||||
const msg: AgentMessage = {
|
|
||||||
role: "user",
|
|
||||||
content: "Hello, world!",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for messages > 50% of context", () => {
|
|
||||||
// Message with ~120K tokens (60% of 200K context)
|
|
||||||
// After safety margin (1.2x), effective is 144K which is > 100K (50%)
|
|
||||||
const msg: AgentMessage = {
|
|
||||||
role: "user",
|
|
||||||
content: "x".repeat(120_000 * 4),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies safety margin", () => {
|
|
||||||
// Message at exactly 50% of context before margin
|
|
||||||
// After SAFETY_MARGIN (1.2), it becomes 60% which is > 50%
|
|
||||||
const halfContextChars = (CONTEXT_WINDOW * 0.5) / SAFETY_MARGIN;
|
|
||||||
const msg: AgentMessage = {
|
|
||||||
role: "user",
|
|
||||||
content: "x".repeat(Math.floor(halfContextChars * 4)),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// With safety margin applied, this should be at the boundary
|
|
||||||
// The function checks if tokens * SAFETY_MARGIN > contextWindow * 0.5
|
|
||||||
const isOversized = isOversizedForSummary(msg, CONTEXT_WINDOW);
|
|
||||||
// Due to token estimation, this could be either true or false at the boundary
|
|
||||||
expect(typeof isOversized).toBe("boolean");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,9 +4,7 @@ import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||||
|
|
||||||
const BASE_CHUNK_RATIO = 0.4;
|
const MAX_CHUNK_RATIO = 0.4;
|
||||||
const MIN_CHUNK_RATIO = 0.15;
|
|
||||||
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
|
|
||||||
const FALLBACK_SUMMARY =
|
const FALLBACK_SUMMARY =
|
||||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||||
const TURN_PREFIX_INSTRUCTIONS =
|
const TURN_PREFIX_INSTRUCTIONS =
|
||||||
@@ -162,38 +160,6 @@ function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessag
|
|||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute adaptive chunk ratio based on average message size.
|
|
||||||
* When messages are large, we use smaller chunks to avoid exceeding model limits.
|
|
||||||
*/
|
|
||||||
function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
|
|
||||||
if (messages.length === 0) return BASE_CHUNK_RATIO;
|
|
||||||
|
|
||||||
const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
|
|
||||||
const avgTokens = totalTokens / messages.length;
|
|
||||||
|
|
||||||
// Apply safety margin to account for estimation inaccuracy
|
|
||||||
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
|
|
||||||
const avgRatio = safeAvgTokens / contextWindow;
|
|
||||||
|
|
||||||
// If average message is > 10% of context, reduce chunk ratio
|
|
||||||
if (avgRatio > 0.1) {
|
|
||||||
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
|
|
||||||
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
|
|
||||||
}
|
|
||||||
|
|
||||||
return BASE_CHUNK_RATIO;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a single message is too large to summarize.
|
|
||||||
* If single message > 50% of context, it can't be summarized safely.
|
|
||||||
*/
|
|
||||||
function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
|
|
||||||
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
|
|
||||||
return tokens > contextWindow * 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function summarizeChunks(params: {
|
async function summarizeChunks(params: {
|
||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
model: NonNullable<ExtensionContext["model"]>;
|
model: NonNullable<ExtensionContext["model"]>;
|
||||||
@@ -226,78 +192,6 @@ async function summarizeChunks(params: {
|
|||||||
return summary ?? "No prior history.";
|
return summary ?? "No prior history.";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Summarize with progressive fallback for handling oversized messages.
|
|
||||||
* If full summarization fails, tries partial summarization excluding oversized messages.
|
|
||||||
*/
|
|
||||||
async function summarizeWithFallback(params: {
|
|
||||||
messages: AgentMessage[];
|
|
||||||
model: NonNullable<ExtensionContext["model"]>;
|
|
||||||
apiKey: string;
|
|
||||||
signal: AbortSignal;
|
|
||||||
reserveTokens: number;
|
|
||||||
maxChunkTokens: number;
|
|
||||||
contextWindow: number;
|
|
||||||
customInstructions?: string;
|
|
||||||
previousSummary?: string;
|
|
||||||
}): Promise<string> {
|
|
||||||
const { messages, contextWindow } = params;
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return params.previousSummary ?? "No prior history.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try full summarization first
|
|
||||||
try {
|
|
||||||
return await summarizeChunks(params);
|
|
||||||
} catch (fullError) {
|
|
||||||
console.warn(
|
|
||||||
`Full summarization failed, trying partial: ${
|
|
||||||
fullError instanceof Error ? fullError.message : String(fullError)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback 1: Summarize only small messages, note oversized ones
|
|
||||||
const smallMessages: AgentMessage[] = [];
|
|
||||||
const oversizedNotes: string[] = [];
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (isOversizedForSummary(msg, contextWindow)) {
|
|
||||||
const role = (msg as { role?: string }).role ?? "message";
|
|
||||||
const tokens = estimateTokens(msg);
|
|
||||||
oversizedNotes.push(
|
|
||||||
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
smallMessages.push(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (smallMessages.length > 0) {
|
|
||||||
try {
|
|
||||||
const partialSummary = await summarizeChunks({
|
|
||||||
...params,
|
|
||||||
messages: smallMessages,
|
|
||||||
});
|
|
||||||
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
|
|
||||||
return partialSummary + notes;
|
|
||||||
} catch (partialError) {
|
|
||||||
console.warn(
|
|
||||||
`Partial summarization also failed: ${
|
|
||||||
partialError instanceof Error ? partialError.message : String(partialError)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final fallback: Just note what was there
|
|
||||||
return (
|
|
||||||
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
|
|
||||||
`Summary unavailable due to size limits.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||||
api.on("session_before_compact", async (event, ctx) => {
|
api.on("session_before_compact", async (event, ctx) => {
|
||||||
const { preparation, customInstructions, signal } = event;
|
const { preparation, customInstructions, signal } = event;
|
||||||
@@ -339,35 +233,29 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
1,
|
1,
|
||||||
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
||||||
);
|
);
|
||||||
|
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * MAX_CHUNK_RATIO));
|
||||||
// Use adaptive chunk ratio based on message sizes
|
|
||||||
const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
|
||||||
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
|
|
||||||
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
|
|
||||||
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
||||||
|
|
||||||
const historySummary = await summarizeWithFallback({
|
const historySummary = await summarizeChunks({
|
||||||
messages: preparation.messagesToSummarize,
|
messages: preparation.messagesToSummarize,
|
||||||
model,
|
model,
|
||||||
apiKey,
|
apiKey,
|
||||||
signal,
|
signal,
|
||||||
reserveTokens,
|
reserveTokens,
|
||||||
maxChunkTokens,
|
maxChunkTokens,
|
||||||
contextWindow: contextWindowTokens,
|
|
||||||
customInstructions,
|
customInstructions,
|
||||||
previousSummary: preparation.previousSummary,
|
previousSummary: preparation.previousSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
let summary = historySummary;
|
let summary = historySummary;
|
||||||
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
|
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
|
||||||
const prefixSummary = await summarizeWithFallback({
|
const prefixSummary = await summarizeChunks({
|
||||||
messages: preparation.turnPrefixMessages,
|
messages: preparation.turnPrefixMessages,
|
||||||
model,
|
model,
|
||||||
apiKey,
|
apiKey,
|
||||||
signal,
|
signal,
|
||||||
reserveTokens,
|
reserveTokens,
|
||||||
maxChunkTokens,
|
maxChunkTokens,
|
||||||
contextWindow: contextWindowTokens,
|
|
||||||
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
||||||
});
|
});
|
||||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||||
@@ -405,9 +293,4 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
export const __testing = {
|
export const __testing = {
|
||||||
collectToolFailures,
|
collectToolFailures,
|
||||||
formatToolFailuresSection,
|
formatToolFailuresSection,
|
||||||
computeAdaptiveChunkRatio,
|
|
||||||
isOversizedForSummary,
|
|
||||||
BASE_CHUNK_RATIO,
|
|
||||||
MIN_CHUNK_RATIO,
|
|
||||||
SAFETY_MARGIN,
|
|
||||||
} as const;
|
} as const;
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||||
|
|
||||||
|
describe("createClawdbotCodingTools", () => {
|
||||||
|
describe("Claude/Gemini alias support", () => {
|
||||||
|
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||||
|
const base: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Path" },
|
||||||
|
content: { type: "string", description: "Body" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||||
|
const params = patched.parameters as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
const props = params.properties ?? {};
|
||||||
|
|
||||||
|
expect(props.file_path).toEqual(props.path);
|
||||||
|
expect(params.required ?? []).not.toContain("path");
|
||||||
|
expect(params.required ?? []).not.toContain("file_path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||||
|
const execute = vi.fn(async (_id, args) => args);
|
||||||
|
const tool: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string" },
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||||
|
|
||||||
|
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
"tool-1",
|
||||||
|
{ path: "foo.txt", content: "x" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
|
||||||
|
const browser = createBrowserTool();
|
||||||
|
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
|
||||||
|
expect(schema.type).toBe("object");
|
||||||
|
expect(schema.anyOf).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("mentions Chrome extension relay in browser tool description", () => {
|
||||||
|
const browser = createBrowserTool();
|
||||||
|
expect(browser.description).toMatch(/Chrome extension/i);
|
||||||
|
expect(browser.description).toMatch(/profile="chrome"/i);
|
||||||
|
});
|
||||||
|
it("keeps browser tool schema properties after normalization", () => {
|
||||||
|
const tools = createClawdbotCodingTools();
|
||||||
|
const browser = tools.find((tool) => tool.name === "browser");
|
||||||
|
expect(browser).toBeDefined();
|
||||||
|
const parameters = browser?.parameters as {
|
||||||
|
anyOf?: unknown[];
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
expect(parameters.properties?.action).toBeDefined();
|
||||||
|
expect(parameters.properties?.target).toBeDefined();
|
||||||
|
expect(parameters.properties?.controlUrl).toBeDefined();
|
||||||
|
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||||
|
expect(parameters.properties?.request).toBeDefined();
|
||||||
|
expect(parameters.required ?? []).toContain("action");
|
||||||
|
});
|
||||||
|
it("exposes raw for gateway config.apply tool calls", () => {
|
||||||
|
const tools = createClawdbotCodingTools();
|
||||||
|
const gateway = tools.find((tool) => tool.name === "gateway");
|
||||||
|
expect(gateway).toBeDefined();
|
||||||
|
|
||||||
|
const parameters = gateway?.parameters as {
|
||||||
|
type?: unknown;
|
||||||
|
required?: string[];
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(parameters.type).toBe("object");
|
||||||
|
expect(parameters.properties?.raw).toBeDefined();
|
||||||
|
expect(parameters.required ?? []).not.toContain("raw");
|
||||||
|
});
|
||||||
|
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
|
||||||
|
const tools = createClawdbotCodingTools();
|
||||||
|
const browser = tools.find((tool) => tool.name === "browser");
|
||||||
|
expect(browser).toBeDefined();
|
||||||
|
|
||||||
|
const parameters = browser?.parameters as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const action = parameters.properties?.action as
|
||||||
|
| {
|
||||||
|
type?: unknown;
|
||||||
|
enum?: unknown[];
|
||||||
|
anyOf?: unknown[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(action?.type).toBe("string");
|
||||||
|
expect(action?.anyOf).toBeUndefined();
|
||||||
|
expect(Array.isArray(action?.enum)).toBe(true);
|
||||||
|
expect(action?.enum).toContain("act");
|
||||||
|
|
||||||
|
const snapshotFormat = parameters.properties?.snapshotFormat as
|
||||||
|
| {
|
||||||
|
type?: unknown;
|
||||||
|
enum?: unknown[];
|
||||||
|
anyOf?: unknown[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
expect(snapshotFormat?.type).toBe("string");
|
||||||
|
expect(snapshotFormat?.anyOf).toBeUndefined();
|
||||||
|
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
|
||||||
|
});
|
||||||
|
it("inlines local $ref before removing unsupported keywords", () => {
|
||||||
|
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
foo: { $ref: "#/$defs/Foo" },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
Foo: { type: "string", enum: ["a", "b"] },
|
||||||
|
},
|
||||||
|
}) as {
|
||||||
|
$defs?: unknown;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(cleaned.$defs).toBeUndefined();
|
||||||
|
expect(cleaned.properties).toBeDefined();
|
||||||
|
expect(cleaned.properties?.foo).toMatchObject({
|
||||||
|
type: "string",
|
||||||
|
enum: ["a", "b"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("cleans tuple items schemas", () => {
|
||||||
|
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tuples: {
|
||||||
|
type: "array",
|
||||||
|
items: [
|
||||||
|
{ type: "string", format: "uuid" },
|
||||||
|
{ type: "number", minimum: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
||||||
|
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
||||||
|
const first = items[0] as { format?: unknown } | undefined;
|
||||||
|
const second = items[1] as { minimum?: unknown } | undefined;
|
||||||
|
|
||||||
|
expect(first?.format).toBeUndefined();
|
||||||
|
expect(second?.minimum).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("drops null-only union variants without flattening other unions", () => {
|
||||||
|
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
|
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||||
|
},
|
||||||
|
}) as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentId = cleaned.properties?.parentId as
|
||||||
|
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||||
|
| undefined;
|
||||||
|
expect(parentId?.anyOf).toBeUndefined();
|
||||||
|
expect(parentId?.oneOf).toBeUndefined();
|
||||||
|
expect(parentId?.type).toBe("string");
|
||||||
|
|
||||||
|
const count = cleaned.properties?.count as
|
||||||
|
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||||
|
| undefined;
|
||||||
|
expect(count?.anyOf).toBeUndefined();
|
||||||
|
expect(Array.isArray(count?.oneOf)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+72
-9
@@ -1,12 +1,74 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import "./test-helpers/fast-coding-tools.js";
|
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
|
||||||
|
|
||||||
const defaultTools = createClawdbotCodingTools();
|
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
describe("createClawdbotCodingTools", () => {
|
||||||
|
describe("Claude/Gemini alias support", () => {
|
||||||
|
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||||
|
const base: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Path" },
|
||||||
|
content: { type: "string", description: "Body" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||||
|
const params = patched.parameters as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
const props = params.properties ?? {};
|
||||||
|
|
||||||
|
expect(props.file_path).toEqual(props.path);
|
||||||
|
expect(params.required ?? []).not.toContain("path");
|
||||||
|
expect(params.required ?? []).not.toContain("file_path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||||
|
const execute = vi.fn(async (_id, args) => args);
|
||||||
|
const tool: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string" },
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||||
|
|
||||||
|
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
"tool-1",
|
||||||
|
{ path: "foo.txt", content: "x" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves action enums in normalized schemas", () => {
|
it("preserves action enums in normalized schemas", () => {
|
||||||
|
const tools = createClawdbotCodingTools();
|
||||||
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
||||||
|
|
||||||
const collectActionValues = (schema: unknown, values: Set<string>): void => {
|
const collectActionValues = (schema: unknown, values: Set<string>): void => {
|
||||||
@@ -26,7 +88,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const name of toolNames) {
|
for (const name of toolNames) {
|
||||||
const tool = defaultTools.find((candidate) => candidate.name === name);
|
const tool = tools.find((candidate) => candidate.name === name);
|
||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
const parameters = tool?.parameters as {
|
const parameters = tool?.parameters as {
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
@@ -46,9 +108,10 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
it("includes exec and process tools by default", () => {
|
it("includes exec and process tools by default", () => {
|
||||||
expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true);
|
const tools = createClawdbotCodingTools();
|
||||||
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
|
expect(tools.some((tool) => tool.name === "exec")).toBe(true);
|
||||||
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
|
expect(tools.some((tool) => tool.name === "process")).toBe(true);
|
||||||
|
expect(tools.some((tool) => tool.name === "apply_patch")).toBe(false);
|
||||||
});
|
});
|
||||||
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
|
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
|
||||||
const config: ClawdbotConfig = {
|
const config: ClawdbotConfig = {
|
||||||
|
|||||||
+68
-6
@@ -1,16 +1,78 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import "./test-helpers/fast-coding-tools.js";
|
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
|
||||||
|
|
||||||
const defaultTools = createClawdbotCodingTools();
|
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
describe("createClawdbotCodingTools", () => {
|
||||||
|
describe("Claude/Gemini alias support", () => {
|
||||||
|
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||||
|
const base: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Path" },
|
||||||
|
content: { type: "string", description: "Body" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||||
|
const params = patched.parameters as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
const props = params.properties ?? {};
|
||||||
|
|
||||||
|
expect(props.file_path).toEqual(props.path);
|
||||||
|
expect(params.required ?? []).not.toContain("path");
|
||||||
|
expect(params.required ?? []).not.toContain("file_path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||||
|
const execute = vi.fn(async (_id, args) => args);
|
||||||
|
const tool: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string" },
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||||
|
|
||||||
|
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
"tool-1",
|
||||||
|
{ path: "foo.txt", content: "x" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps read tool image metadata intact", async () => {
|
it("keeps read tool image metadata intact", async () => {
|
||||||
const readTool = defaultTools.find((tool) => tool.name === "read");
|
const tools = createClawdbotCodingTools();
|
||||||
|
const readTool = tools.find((tool) => tool.name === "read");
|
||||||
expect(readTool).toBeDefined();
|
expect(readTool).toBeDefined();
|
||||||
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
|
||||||
|
describe("createClawdbotCodingTools", () => {
|
||||||
|
describe("Claude/Gemini alias support", () => {
|
||||||
|
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||||
|
const base: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Path" },
|
||||||
|
content: { type: "string", description: "Body" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||||
|
const params = patched.parameters as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
const props = params.properties ?? {};
|
||||||
|
|
||||||
|
expect(props.file_path).toEqual(props.path);
|
||||||
|
expect(params.required ?? []).not.toContain("path");
|
||||||
|
expect(params.required ?? []).not.toContain("file_path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||||
|
const execute = vi.fn(async (_id, args) => args);
|
||||||
|
const tool: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string" },
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||||
|
|
||||||
|
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
"tool-1",
|
||||||
|
{ path: "foo.txt", content: "x" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies tool profiles before allow/deny policies", () => {
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
config: { tools: { profile: "messaging" } },
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
|
expect(names.has("message")).toBe(true);
|
||||||
|
expect(names.has("sessions_send")).toBe(true);
|
||||||
|
expect(names.has("sessions_spawn")).toBe(false);
|
||||||
|
expect(names.has("exec")).toBe(false);
|
||||||
|
expect(names.has("browser")).toBe(false);
|
||||||
|
});
|
||||||
|
it("expands group shorthands in global tool policy", () => {
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
config: { tools: { allow: ["group:fs"] } },
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
|
expect(names.has("read")).toBe(true);
|
||||||
|
expect(names.has("write")).toBe(true);
|
||||||
|
expect(names.has("edit")).toBe(true);
|
||||||
|
expect(names.has("exec")).toBe(false);
|
||||||
|
expect(names.has("browser")).toBe(false);
|
||||||
|
});
|
||||||
|
it("expands group shorthands in global tool deny policy", () => {
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
config: { tools: { deny: ["group:fs"] } },
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
|
expect(names.has("read")).toBe(false);
|
||||||
|
expect(names.has("write")).toBe(false);
|
||||||
|
expect(names.has("edit")).toBe(false);
|
||||||
|
expect(names.has("exec")).toBe(true);
|
||||||
|
});
|
||||||
|
it("lets agent profiles override global profiles", () => {
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
sessionKey: "agent:work:main",
|
||||||
|
config: {
|
||||||
|
tools: { profile: "coding" },
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "work", tools: { profile: "messaging" } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
|
expect(names.has("message")).toBe(true);
|
||||||
|
expect(names.has("exec")).toBe(false);
|
||||||
|
expect(names.has("read")).toBe(false);
|
||||||
|
});
|
||||||
|
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
||||||
|
const tools = createClawdbotCodingTools();
|
||||||
|
|
||||||
|
// Helper to recursively check schema for unsupported keywords
|
||||||
|
const unsupportedKeywords = new Set([
|
||||||
|
"patternProperties",
|
||||||
|
"additionalProperties",
|
||||||
|
"$schema",
|
||||||
|
"$id",
|
||||||
|
"$ref",
|
||||||
|
"$defs",
|
||||||
|
"definitions",
|
||||||
|
"examples",
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"minimum",
|
||||||
|
"maximum",
|
||||||
|
"multipleOf",
|
||||||
|
"pattern",
|
||||||
|
"format",
|
||||||
|
"minItems",
|
||||||
|
"maxItems",
|
||||||
|
"uniqueItems",
|
||||||
|
"minProperties",
|
||||||
|
"maxProperties",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
|
||||||
|
const found: string[] = [];
|
||||||
|
if (!schema || typeof schema !== "object") return found;
|
||||||
|
if (Array.isArray(schema)) {
|
||||||
|
schema.forEach((item, i) => {
|
||||||
|
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = schema as Record<string, unknown>;
|
||||||
|
const properties =
|
||||||
|
record.properties &&
|
||||||
|
typeof record.properties === "object" &&
|
||||||
|
!Array.isArray(record.properties)
|
||||||
|
? (record.properties as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
if (properties) {
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (key === "properties") continue;
|
||||||
|
if (unsupportedKeywords.has(key)) {
|
||||||
|
found.push(`${path}.${key}`);
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+66
-3
@@ -1,11 +1,74 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import "./test-helpers/fast-coding-tools.js";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
describe("createClawdbotCodingTools", () => {
|
||||||
|
describe("Claude/Gemini alias support", () => {
|
||||||
|
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||||
|
const base: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Path" },
|
||||||
|
content: { type: "string", description: "Body" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||||
|
const params = patched.parameters as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
const props = params.properties ?? {};
|
||||||
|
|
||||||
|
expect(props.file_path).toEqual(props.path);
|
||||||
|
expect(params.required ?? []).not.toContain("path");
|
||||||
|
expect(params.required ?? []).not.toContain("file_path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||||
|
const execute = vi.fn(async (_id, args) => args);
|
||||||
|
const tool: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string" },
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||||
|
|
||||||
|
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
"tool-1",
|
||||||
|
{ path: "foo.txt", content: "x" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("uses workspaceDir for Read tool path resolution", async () => {
|
it("uses workspaceDir for Read tool path resolution", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
||||||
try {
|
try {
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
import { createSandboxedReadTool } from "./pi-tools.read.js";
|
||||||
|
|
||||||
|
describe("createClawdbotCodingTools", () => {
|
||||||
|
describe("Claude/Gemini alias support", () => {
|
||||||
|
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
||||||
|
const base: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Path" },
|
||||||
|
content: { type: "string", description: "Body" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
||||||
|
const params = patched.parameters as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
const props = params.properties ?? {};
|
||||||
|
|
||||||
|
expect(props.file_path).toEqual(props.path);
|
||||||
|
expect(params.required ?? []).not.toContain("path");
|
||||||
|
expect(params.required ?? []).not.toContain("file_path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
||||||
|
const execute = vi.fn(async (_id, args) => args);
|
||||||
|
const tool: AgentTool = {
|
||||||
|
name: "write",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["path", "content"],
|
||||||
|
properties: {
|
||||||
|
path: { type: "string" },
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
||||||
|
|
||||||
|
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
"tool-1",
|
||||||
|
{ path: "foo.txt", content: "x" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
||||||
|
/Missing required parameter/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies sandbox path guards to file_path alias", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
|
||||||
|
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
|
||||||
|
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||||
|
try {
|
||||||
|
const readTool = createSandboxedReadTool(tmpDir);
|
||||||
|
await expect(readTool.execute("tool-sbx-1", { file_path: outsidePath })).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
await fs.rm(outsidePath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("falls back to process.cwd() when workspaceDir not provided", () => {
|
||||||
|
const prevCwd = process.cwd();
|
||||||
|
const tools = createClawdbotCodingTools();
|
||||||
|
// Tools should be created without error
|
||||||
|
expect(tools.some((tool) => tool.name === "read")).toBe(true);
|
||||||
|
expect(tools.some((tool) => tool.name === "write")).toBe(true);
|
||||||
|
expect(tools.some((tool) => tool.name === "edit")).toBe(true);
|
||||||
|
// cwd should be unchanged
|
||||||
|
expect(process.cwd()).toBe(prevCwd);
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-273
@@ -1,15 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import "./test-helpers/fast-coding-tools.js";
|
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import { createSandboxedReadTool } from "./pi-tools.read.js";
|
|
||||||
import { createBrowserTool } from "./tools/browser-tool.js";
|
|
||||||
|
|
||||||
const defaultTools = createClawdbotCodingTools();
|
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
describe("createClawdbotCodingTools", () => {
|
||||||
describe("Claude/Gemini alias support", () => {
|
describe("Claude/Gemini alias support", () => {
|
||||||
@@ -75,144 +67,8 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
|
|
||||||
const browser = createBrowserTool();
|
|
||||||
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
|
|
||||||
expect(schema.type).toBe("object");
|
|
||||||
expect(schema.anyOf).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("mentions Chrome extension relay in browser tool description", () => {
|
|
||||||
const browser = createBrowserTool();
|
|
||||||
expect(browser.description).toMatch(/Chrome extension/i);
|
|
||||||
expect(browser.description).toMatch(/profile="chrome"/i);
|
|
||||||
});
|
|
||||||
it("keeps browser tool schema properties after normalization", () => {
|
|
||||||
const browser = defaultTools.find((tool) => tool.name === "browser");
|
|
||||||
expect(browser).toBeDefined();
|
|
||||||
const parameters = browser?.parameters as {
|
|
||||||
anyOf?: unknown[];
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
expect(parameters.properties?.action).toBeDefined();
|
|
||||||
expect(parameters.properties?.target).toBeDefined();
|
|
||||||
expect(parameters.properties?.controlUrl).toBeDefined();
|
|
||||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
|
||||||
expect(parameters.properties?.request).toBeDefined();
|
|
||||||
expect(parameters.required ?? []).toContain("action");
|
|
||||||
});
|
|
||||||
it("exposes raw for gateway config.apply tool calls", () => {
|
|
||||||
const gateway = defaultTools.find((tool) => tool.name === "gateway");
|
|
||||||
expect(gateway).toBeDefined();
|
|
||||||
|
|
||||||
const parameters = gateway?.parameters as {
|
|
||||||
type?: unknown;
|
|
||||||
required?: string[];
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
expect(parameters.type).toBe("object");
|
|
||||||
expect(parameters.properties?.raw).toBeDefined();
|
|
||||||
expect(parameters.required ?? []).not.toContain("raw");
|
|
||||||
});
|
|
||||||
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
|
|
||||||
const browser = defaultTools.find((tool) => tool.name === "browser");
|
|
||||||
expect(browser).toBeDefined();
|
|
||||||
|
|
||||||
const parameters = browser?.parameters as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
const action = parameters.properties?.action as
|
|
||||||
| {
|
|
||||||
type?: unknown;
|
|
||||||
enum?: unknown[];
|
|
||||||
anyOf?: unknown[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
expect(action?.type).toBe("string");
|
|
||||||
expect(action?.anyOf).toBeUndefined();
|
|
||||||
expect(Array.isArray(action?.enum)).toBe(true);
|
|
||||||
expect(action?.enum).toContain("act");
|
|
||||||
|
|
||||||
const snapshotFormat = parameters.properties?.snapshotFormat as
|
|
||||||
| {
|
|
||||||
type?: unknown;
|
|
||||||
enum?: unknown[];
|
|
||||||
anyOf?: unknown[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
expect(snapshotFormat?.type).toBe("string");
|
|
||||||
expect(snapshotFormat?.anyOf).toBeUndefined();
|
|
||||||
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
|
|
||||||
});
|
|
||||||
it("inlines local $ref before removing unsupported keywords", () => {
|
|
||||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
foo: { $ref: "#/$defs/Foo" },
|
|
||||||
},
|
|
||||||
$defs: {
|
|
||||||
Foo: { type: "string", enum: ["a", "b"] },
|
|
||||||
},
|
|
||||||
}) as {
|
|
||||||
$defs?: unknown;
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(cleaned.$defs).toBeUndefined();
|
|
||||||
expect(cleaned.properties).toBeDefined();
|
|
||||||
expect(cleaned.properties?.foo).toMatchObject({
|
|
||||||
type: "string",
|
|
||||||
enum: ["a", "b"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it("cleans tuple items schemas", () => {
|
|
||||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
tuples: {
|
|
||||||
type: "array",
|
|
||||||
items: [
|
|
||||||
{ type: "string", format: "uuid" },
|
|
||||||
{ type: "number", minimum: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
|
||||||
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
|
||||||
const first = items[0] as { format?: unknown } | undefined;
|
|
||||||
const second = items[1] as { minimum?: unknown } | undefined;
|
|
||||||
|
|
||||||
expect(first?.format).toBeUndefined();
|
|
||||||
expect(second?.minimum).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("drops null-only union variants without flattening other unions", () => {
|
|
||||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
||||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
||||||
},
|
|
||||||
}) as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parentId = cleaned.properties?.parentId as
|
|
||||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
|
||||||
| undefined;
|
|
||||||
const count = cleaned.properties?.count as
|
|
||||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
expect(parentId?.type).toBe("string");
|
|
||||||
expect(parentId?.anyOf).toBeUndefined();
|
|
||||||
expect(count?.oneOf).toBeDefined();
|
|
||||||
});
|
|
||||||
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
|
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
|
||||||
|
const tools = createClawdbotCodingTools();
|
||||||
const offenders: Array<{
|
const offenders: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
@@ -240,7 +96,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const tool of defaultTools) {
|
for (const tool of tools) {
|
||||||
walk(tool.parameters, "", tool.name);
|
walk(tool.parameters, "", tool.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,131 +192,4 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
});
|
});
|
||||||
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies tool profiles before allow/deny policies", () => {
|
|
||||||
const tools = createClawdbotCodingTools({
|
|
||||||
config: { tools: { profile: "messaging" } },
|
|
||||||
});
|
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
|
||||||
expect(names.has("message")).toBe(true);
|
|
||||||
expect(names.has("sessions_send")).toBe(true);
|
|
||||||
expect(names.has("sessions_spawn")).toBe(false);
|
|
||||||
expect(names.has("exec")).toBe(false);
|
|
||||||
expect(names.has("browser")).toBe(false);
|
|
||||||
});
|
|
||||||
it("expands group shorthands in global tool policy", () => {
|
|
||||||
const tools = createClawdbotCodingTools({
|
|
||||||
config: { tools: { allow: ["group:fs"] } },
|
|
||||||
});
|
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
|
||||||
expect(names.has("read")).toBe(true);
|
|
||||||
expect(names.has("write")).toBe(true);
|
|
||||||
expect(names.has("edit")).toBe(true);
|
|
||||||
expect(names.has("exec")).toBe(false);
|
|
||||||
expect(names.has("browser")).toBe(false);
|
|
||||||
});
|
|
||||||
it("expands group shorthands in global tool deny policy", () => {
|
|
||||||
const tools = createClawdbotCodingTools({
|
|
||||||
config: { tools: { deny: ["group:fs"] } },
|
|
||||||
});
|
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
|
||||||
expect(names.has("read")).toBe(false);
|
|
||||||
expect(names.has("write")).toBe(false);
|
|
||||||
expect(names.has("edit")).toBe(false);
|
|
||||||
expect(names.has("exec")).toBe(true);
|
|
||||||
});
|
|
||||||
it("lets agent profiles override global profiles", () => {
|
|
||||||
const tools = createClawdbotCodingTools({
|
|
||||||
sessionKey: "agent:work:main",
|
|
||||||
config: {
|
|
||||||
tools: { profile: "coding" },
|
|
||||||
agents: {
|
|
||||||
list: [{ id: "work", tools: { profile: "messaging" } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
|
||||||
expect(names.has("message")).toBe(true);
|
|
||||||
expect(names.has("exec")).toBe(false);
|
|
||||||
expect(names.has("read")).toBe(false);
|
|
||||||
});
|
|
||||||
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
|
||||||
// Helper to recursively check schema for unsupported keywords
|
|
||||||
const unsupportedKeywords = new Set([
|
|
||||||
"patternProperties",
|
|
||||||
"additionalProperties",
|
|
||||||
"$schema",
|
|
||||||
"$id",
|
|
||||||
"$ref",
|
|
||||||
"$defs",
|
|
||||||
"definitions",
|
|
||||||
"examples",
|
|
||||||
"minLength",
|
|
||||||
"maxLength",
|
|
||||||
"minimum",
|
|
||||||
"maximum",
|
|
||||||
"multipleOf",
|
|
||||||
"pattern",
|
|
||||||
"format",
|
|
||||||
"minItems",
|
|
||||||
"maxItems",
|
|
||||||
"uniqueItems",
|
|
||||||
"minProperties",
|
|
||||||
"maxProperties",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
|
|
||||||
const found: string[] = [];
|
|
||||||
if (!schema || typeof schema !== "object") return found;
|
|
||||||
if (Array.isArray(schema)) {
|
|
||||||
schema.forEach((item, i) => {
|
|
||||||
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
|
|
||||||
});
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = schema as Record<string, unknown>;
|
|
||||||
const properties =
|
|
||||||
record.properties &&
|
|
||||||
typeof record.properties === "object" &&
|
|
||||||
!Array.isArray(record.properties)
|
|
||||||
? (record.properties as Record<string, unknown>)
|
|
||||||
: undefined;
|
|
||||||
if (properties) {
|
|
||||||
for (const [key, value] of Object.entries(properties)) {
|
|
||||||
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(record)) {
|
|
||||||
if (key === "properties") continue;
|
|
||||||
if (unsupportedKeywords.has(key)) {
|
|
||||||
found.push(`${path}.${key}`);
|
|
||||||
}
|
|
||||||
if (value && typeof value === "object") {
|
|
||||||
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const tool of defaultTools) {
|
|
||||||
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
|
|
||||||
expect(violations).toEqual([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
it("applies sandbox path guards to file_path alias", async () => {
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
|
|
||||||
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
|
|
||||||
await fs.writeFile(outsidePath, "outside", "utf8");
|
|
||||||
try {
|
|
||||||
const readTool = createSandboxedReadTool(tmpDir);
|
|
||||||
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
|
|
||||||
/sandbox root/i,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await fs.rm(outsidePath, { force: true });
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+6
-25
@@ -44,7 +44,6 @@ import {
|
|||||||
collectExplicitAllowlist,
|
collectExplicitAllowlist,
|
||||||
expandPolicyWithPluginGroups,
|
expandPolicyWithPluginGroups,
|
||||||
resolveToolProfilePolicy,
|
resolveToolProfilePolicy,
|
||||||
stripPluginOnlyAllowlist,
|
|
||||||
} from "./tool-policy.js";
|
} from "./tool-policy.js";
|
||||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||||
|
|
||||||
@@ -299,30 +298,12 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
tools,
|
tools,
|
||||||
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
||||||
});
|
});
|
||||||
const profilePolicyExpanded = expandPolicyWithPluginGroups(
|
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
|
||||||
stripPluginOnlyAllowlist(profilePolicy, pluginGroups),
|
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);
|
||||||
pluginGroups,
|
const globalPolicyExpanded = expandPolicyWithPluginGroups(globalPolicy, pluginGroups);
|
||||||
);
|
const globalProviderExpanded = expandPolicyWithPluginGroups(globalProviderPolicy, pluginGroups);
|
||||||
const providerProfileExpanded = expandPolicyWithPluginGroups(
|
const agentPolicyExpanded = expandPolicyWithPluginGroups(agentPolicy, pluginGroups);
|
||||||
stripPluginOnlyAllowlist(providerProfilePolicy, pluginGroups),
|
const agentProviderExpanded = expandPolicyWithPluginGroups(agentProviderPolicy, pluginGroups);
|
||||||
pluginGroups,
|
|
||||||
);
|
|
||||||
const globalPolicyExpanded = expandPolicyWithPluginGroups(
|
|
||||||
stripPluginOnlyAllowlist(globalPolicy, pluginGroups),
|
|
||||||
pluginGroups,
|
|
||||||
);
|
|
||||||
const globalProviderExpanded = expandPolicyWithPluginGroups(
|
|
||||||
stripPluginOnlyAllowlist(globalProviderPolicy, pluginGroups),
|
|
||||||
pluginGroups,
|
|
||||||
);
|
|
||||||
const agentPolicyExpanded = expandPolicyWithPluginGroups(
|
|
||||||
stripPluginOnlyAllowlist(agentPolicy, pluginGroups),
|
|
||||||
pluginGroups,
|
|
||||||
);
|
|
||||||
const agentProviderExpanded = expandPolicyWithPluginGroups(
|
|
||||||
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
|
|
||||||
pluginGroups,
|
|
||||||
);
|
|
||||||
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
||||||
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { acquireSessionWriteLock } from "./session-write-lock.js";
|
|
||||||
|
|
||||||
describe("acquireSessionWriteLock", () => {
|
|
||||||
it("reuses locks across symlinked session paths", async () => {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
expect(true).toBe(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
|
||||||
try {
|
|
||||||
const realDir = path.join(root, "real");
|
|
||||||
const linkDir = path.join(root, "link");
|
|
||||||
await fs.mkdir(realDir, { recursive: true });
|
|
||||||
await fs.symlink(realDir, linkDir);
|
|
||||||
|
|
||||||
const sessionReal = path.join(realDir, "sessions.json");
|
|
||||||
const sessionLink = path.join(linkDir, "sessions.json");
|
|
||||||
|
|
||||||
const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 });
|
|
||||||
const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 });
|
|
||||||
|
|
||||||
await lockB.release();
|
|
||||||
await lockA.release();
|
|
||||||
} finally {
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -45,28 +45,20 @@ export async function acquireSessionWriteLock(params: {
|
|||||||
}> {
|
}> {
|
||||||
const timeoutMs = params.timeoutMs ?? 10_000;
|
const timeoutMs = params.timeoutMs ?? 10_000;
|
||||||
const staleMs = params.staleMs ?? 30 * 60 * 1000;
|
const staleMs = params.staleMs ?? 30 * 60 * 1000;
|
||||||
const sessionFile = path.resolve(params.sessionFile);
|
const sessionFile = params.sessionFile;
|
||||||
const sessionDir = path.dirname(sessionFile);
|
const lockPath = `${sessionFile}.lock`;
|
||||||
await fs.mkdir(sessionDir, { recursive: true });
|
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||||
let normalizedDir = sessionDir;
|
|
||||||
try {
|
|
||||||
normalizedDir = await fs.realpath(sessionDir);
|
|
||||||
} catch {
|
|
||||||
// Fall back to the resolved path if realpath fails (permissions, transient FS).
|
|
||||||
}
|
|
||||||
const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile));
|
|
||||||
const lockPath = `${normalizedSessionFile}.lock`;
|
|
||||||
|
|
||||||
const held = HELD_LOCKS.get(normalizedSessionFile);
|
const held = HELD_LOCKS.get(sessionFile);
|
||||||
if (held) {
|
if (held) {
|
||||||
held.count += 1;
|
held.count += 1;
|
||||||
return {
|
return {
|
||||||
release: async () => {
|
release: async () => {
|
||||||
const current = HELD_LOCKS.get(normalizedSessionFile);
|
const current = HELD_LOCKS.get(sessionFile);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
current.count -= 1;
|
current.count -= 1;
|
||||||
if (current.count > 0) return;
|
if (current.count > 0) return;
|
||||||
HELD_LOCKS.delete(normalizedSessionFile);
|
HELD_LOCKS.delete(sessionFile);
|
||||||
await current.handle.close();
|
await current.handle.close();
|
||||||
await fs.rm(current.lockPath, { force: true });
|
await fs.rm(current.lockPath, { force: true });
|
||||||
},
|
},
|
||||||
@@ -83,14 +75,14 @@ export async function acquireSessionWriteLock(params: {
|
|||||||
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
|
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath });
|
HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath });
|
||||||
return {
|
return {
|
||||||
release: async () => {
|
release: async () => {
|
||||||
const current = HELD_LOCKS.get(normalizedSessionFile);
|
const current = HELD_LOCKS.get(sessionFile);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
current.count -= 1;
|
current.count -= 1;
|
||||||
if (current.count > 0) return;
|
if (current.count > 0) return;
|
||||||
HELD_LOCKS.delete(normalizedSessionFile);
|
HELD_LOCKS.delete(sessionFile);
|
||||||
await current.handle.close();
|
await current.handle.close();
|
||||||
await fs.rm(current.lockPath, { force: true });
|
await fs.rm(current.lockPath, { force: true });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { vi } from "vitest";
|
|
||||||
|
|
||||||
const stubTool = (name: string) => ({
|
|
||||||
name,
|
|
||||||
description: `${name} stub`,
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
execute: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../tools/image-tool.js", () => ({
|
|
||||||
createImageTool: () => stubTool("image"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../tools/web-tools.js", () => ({
|
|
||||||
createWebSearchTool: () => null,
|
|
||||||
createWebFetchTool: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../plugins/tools.js", () => ({
|
|
||||||
resolvePluginTools: () => [],
|
|
||||||
getPluginToolMeta: () => undefined,
|
|
||||||
}));
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { vi } from "vitest";
|
|
||||||
|
|
||||||
const stubTool = (name: string) => ({
|
|
||||||
name,
|
|
||||||
description: `${name} stub`,
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
execute: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../tools/browser-tool.js", () => ({
|
|
||||||
createBrowserTool: () => stubTool("browser"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../tools/canvas-tool.js", () => ({
|
|
||||||
createCanvasTool: () => stubTool("canvas"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../tools/image-tool.js", () => ({
|
|
||||||
createImageTool: () => stubTool("image"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../tools/web-tools.js", () => ({
|
|
||||||
createWebSearchTool: () => null,
|
|
||||||
createWebFetchTool: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../plugins/tools.js", () => ({
|
|
||||||
resolvePluginTools: () => [],
|
|
||||||
getPluginToolMeta: () => undefined,
|
|
||||||
}));
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { stripPluginOnlyAllowlist, type PluginToolGroups } from "./tool-policy.js";
|
|
||||||
|
|
||||||
const pluginGroups: PluginToolGroups = {
|
|
||||||
all: ["lobster", "workflow_tool"],
|
|
||||||
byPlugin: new Map([["lobster", ["lobster", "workflow_tool"]]]),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("stripPluginOnlyAllowlist", () => {
|
|
||||||
it("strips allowlist when it only targets plugin tools", () => {
|
|
||||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups);
|
|
||||||
expect(policy?.allow).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips allowlist when it only targets plugin groups", () => {
|
|
||||||
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups);
|
|
||||||
expect(policy?.allow).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps allowlist when it mixes plugin and core entries", () => {
|
|
||||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster", "read"] }, pluginGroups);
|
|
||||||
expect(policy?.allow).toEqual(["lobster", "read"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -178,22 +178,6 @@ export function expandPolicyWithPluginGroups(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripPluginOnlyAllowlist(
|
|
||||||
policy: ToolPolicyLike | undefined,
|
|
||||||
groups: PluginToolGroups,
|
|
||||||
): ToolPolicyLike | undefined {
|
|
||||||
if (!policy?.allow || policy.allow.length === 0) return policy;
|
|
||||||
const normalized = normalizeToolList(policy.allow);
|
|
||||||
if (normalized.length === 0) return policy;
|
|
||||||
const pluginIds = new Set(groups.byPlugin.keys());
|
|
||||||
const pluginTools = new Set(groups.all);
|
|
||||||
const isPluginEntry = (entry: string) =>
|
|
||||||
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
|
|
||||||
const isPluginOnly = normalized.every((entry) => isPluginEntry(entry));
|
|
||||||
if (!isPluginOnly) return policy;
|
|
||||||
return { ...policy, allow: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
||||||
if (!profile) return undefined;
|
if (!profile) return undefined;
|
||||||
const resolved = TOOL_PROFILES[profile as ToolProfileId];
|
const resolved = TOOL_PROFILES[profile as ToolProfileId];
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ vi.mock("../../gateway/call.js", () => ({
|
|||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../agent-scope.js", () => ({
|
|
||||||
resolveSessionAgentId: () => "agent-123",
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createCronTool } from "./cron-tool.js";
|
import { createCronTool } from "./cron-tool.js";
|
||||||
|
|
||||||
describe("cron tool", () => {
|
describe("cron tool", () => {
|
||||||
@@ -89,23 +85,6 @@ describe("cron tool", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not default agentId when job.agentId is null", async () => {
|
|
||||||
const tool = createCronTool({ agentSessionKey: "main" });
|
|
||||||
await tool.execute("call-null", {
|
|
||||||
action: "add",
|
|
||||||
job: {
|
|
||||||
name: "wake-up",
|
|
||||||
schedule: { atMs: 123 },
|
|
||||||
agentId: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
|
||||||
params?: { agentId?: unknown };
|
|
||||||
};
|
|
||||||
expect(call?.params?.agentId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
|
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
|
||||||
callGatewayMock
|
callGatewayMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -209,26 +188,4 @@ describe("cron tool", () => {
|
|||||||
const text = cronCall.params?.payload?.text ?? "";
|
const text = cronCall.params?.payload?.text ?? "";
|
||||||
expect(text).not.toContain("Recent context:");
|
expect(text).not.toContain("Recent context:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves explicit agentId null on add", async () => {
|
|
||||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
|
||||||
|
|
||||||
const tool = createCronTool({ agentSessionKey: "main" });
|
|
||||||
await tool.execute("call6", {
|
|
||||||
action: "add",
|
|
||||||
job: {
|
|
||||||
name: "reminder",
|
|
||||||
schedule: { atMs: 123 },
|
|
||||||
agentId: null,
|
|
||||||
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
|
||||||
method?: string;
|
|
||||||
params?: { agentId?: string | null };
|
|
||||||
};
|
|
||||||
expect(call.method).toBe("cron.add");
|
|
||||||
expect(call.params?.agentId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -3,7 +3,6 @@ import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normal
|
|||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
|
||||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||||
@@ -159,15 +158,6 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
|
|||||||
throw new Error("job required");
|
throw new Error("job required");
|
||||||
}
|
}
|
||||||
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
||||||
if (job && typeof job === "object" && !("agentId" in job)) {
|
|
||||||
const cfg = loadConfig();
|
|
||||||
const agentId = opts?.agentSessionKey
|
|
||||||
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
|
||||||
: undefined;
|
|
||||||
if (agentId) {
|
|
||||||
(job as { agentId?: string }).agentId = agentId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const contextMessages =
|
const contextMessages =
|
||||||
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
|
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
|
||||||
? params.contextMessages
|
? params.contextMessages
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export async function handleDiscordGuildAction(
|
|||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
const accountId = readStringParam(params, "accountId");
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "memberInfo": {
|
case "memberInfo": {
|
||||||
if (!isActionEnabled("memberInfo")) {
|
if (!isActionEnabled("memberInfo")) {
|
||||||
@@ -51,9 +50,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const userId = readStringParam(params, "userId", {
|
const userId = readStringParam(params, "userId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const member = accountId
|
const member = await fetchMemberInfoDiscord(guildId, userId);
|
||||||
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
|
|
||||||
: await fetchMemberInfoDiscord(guildId, userId);
|
|
||||||
return jsonResult({ ok: true, member });
|
return jsonResult({ ok: true, member });
|
||||||
}
|
}
|
||||||
case "roleInfo": {
|
case "roleInfo": {
|
||||||
@@ -63,9 +60,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roles = accountId
|
const roles = await fetchRoleInfoDiscord(guildId);
|
||||||
? await fetchRoleInfoDiscord(guildId, { accountId })
|
|
||||||
: await fetchRoleInfoDiscord(guildId);
|
|
||||||
return jsonResult({ ok: true, roles });
|
return jsonResult({ ok: true, roles });
|
||||||
}
|
}
|
||||||
case "emojiList": {
|
case "emojiList": {
|
||||||
@@ -75,9 +70,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const emojis = accountId
|
const emojis = await listGuildEmojisDiscord(guildId);
|
||||||
? await listGuildEmojisDiscord(guildId, { accountId })
|
|
||||||
: await listGuildEmojisDiscord(guildId);
|
|
||||||
return jsonResult({ ok: true, emojis });
|
return jsonResult({ ok: true, emojis });
|
||||||
}
|
}
|
||||||
case "emojiUpload": {
|
case "emojiUpload": {
|
||||||
@@ -92,22 +85,12 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleIds = readStringArrayParam(params, "roleIds");
|
const roleIds = readStringArrayParam(params, "roleIds");
|
||||||
const emoji = accountId
|
const emoji = await uploadEmojiDiscord({
|
||||||
? await uploadEmojiDiscord(
|
guildId,
|
||||||
{
|
name,
|
||||||
guildId,
|
mediaUrl,
|
||||||
name,
|
roleIds: roleIds?.length ? roleIds : undefined,
|
||||||
mediaUrl,
|
});
|
||||||
roleIds: roleIds?.length ? roleIds : undefined,
|
|
||||||
},
|
|
||||||
{ accountId },
|
|
||||||
)
|
|
||||||
: await uploadEmojiDiscord({
|
|
||||||
guildId,
|
|
||||||
name,
|
|
||||||
mediaUrl,
|
|
||||||
roleIds: roleIds?.length ? roleIds : undefined,
|
|
||||||
});
|
|
||||||
return jsonResult({ ok: true, emoji });
|
return jsonResult({ ok: true, emoji });
|
||||||
}
|
}
|
||||||
case "stickerUpload": {
|
case "stickerUpload": {
|
||||||
@@ -125,24 +108,13 @@ export async function handleDiscordGuildAction(
|
|||||||
const mediaUrl = readStringParam(params, "mediaUrl", {
|
const mediaUrl = readStringParam(params, "mediaUrl", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const sticker = accountId
|
const sticker = await uploadStickerDiscord({
|
||||||
? await uploadStickerDiscord(
|
guildId,
|
||||||
{
|
name,
|
||||||
guildId,
|
description,
|
||||||
name,
|
tags,
|
||||||
description,
|
mediaUrl,
|
||||||
tags,
|
});
|
||||||
mediaUrl,
|
|
||||||
},
|
|
||||||
{ accountId },
|
|
||||||
)
|
|
||||||
: await uploadStickerDiscord({
|
|
||||||
guildId,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tags,
|
|
||||||
mediaUrl,
|
|
||||||
});
|
|
||||||
return jsonResult({ ok: true, sticker });
|
return jsonResult({ ok: true, sticker });
|
||||||
}
|
}
|
||||||
case "roleAdd": {
|
case "roleAdd": {
|
||||||
@@ -156,11 +128,7 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleId = readStringParam(params, "roleId", { required: true });
|
const roleId = readStringParam(params, "roleId", { required: true });
|
||||||
if (accountId) {
|
await addRoleDiscord({ guildId, userId, roleId });
|
||||||
await addRoleDiscord({ guildId, userId, roleId }, { accountId });
|
|
||||||
} else {
|
|
||||||
await addRoleDiscord({ guildId, userId, roleId });
|
|
||||||
}
|
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "roleRemove": {
|
case "roleRemove": {
|
||||||
@@ -174,11 +142,7 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleId = readStringParam(params, "roleId", { required: true });
|
const roleId = readStringParam(params, "roleId", { required: true });
|
||||||
if (accountId) {
|
await removeRoleDiscord({ guildId, userId, roleId });
|
||||||
await removeRoleDiscord({ guildId, userId, roleId }, { accountId });
|
|
||||||
} else {
|
|
||||||
await removeRoleDiscord({ guildId, userId, roleId });
|
|
||||||
}
|
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "channelInfo": {
|
case "channelInfo": {
|
||||||
@@ -188,9 +152,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const channel = accountId
|
const channel = await fetchChannelInfoDiscord(channelId);
|
||||||
? await fetchChannelInfoDiscord(channelId, { accountId })
|
|
||||||
: await fetchChannelInfoDiscord(channelId);
|
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelList": {
|
case "channelList": {
|
||||||
@@ -200,9 +162,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const channels = accountId
|
const channels = await listGuildChannelsDiscord(guildId);
|
||||||
? await listGuildChannelsDiscord(guildId, { accountId })
|
|
||||||
: await listGuildChannelsDiscord(guildId);
|
|
||||||
return jsonResult({ ok: true, channels });
|
return jsonResult({ ok: true, channels });
|
||||||
}
|
}
|
||||||
case "voiceStatus": {
|
case "voiceStatus": {
|
||||||
@@ -215,9 +175,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const userId = readStringParam(params, "userId", {
|
const userId = readStringParam(params, "userId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const voice = accountId
|
const voice = await fetchVoiceStatusDiscord(guildId, userId);
|
||||||
? await fetchVoiceStatusDiscord(guildId, userId, { accountId })
|
|
||||||
: await fetchVoiceStatusDiscord(guildId, userId);
|
|
||||||
return jsonResult({ ok: true, voice });
|
return jsonResult({ ok: true, voice });
|
||||||
}
|
}
|
||||||
case "eventList": {
|
case "eventList": {
|
||||||
@@ -227,9 +185,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const events = accountId
|
const events = await listScheduledEventsDiscord(guildId);
|
||||||
? await listScheduledEventsDiscord(guildId, { accountId })
|
|
||||||
: await listScheduledEventsDiscord(guildId);
|
|
||||||
return jsonResult({ ok: true, events });
|
return jsonResult({ ok: true, events });
|
||||||
}
|
}
|
||||||
case "eventCreate": {
|
case "eventCreate": {
|
||||||
@@ -259,9 +215,7 @@ export async function handleDiscordGuildAction(
|
|||||||
entity_metadata: entityType === 3 && location ? { location } : undefined,
|
entity_metadata: entityType === 3 && location ? { location } : undefined,
|
||||||
privacy_level: 2,
|
privacy_level: 2,
|
||||||
};
|
};
|
||||||
const event = accountId
|
const event = await createScheduledEventDiscord(guildId, payload);
|
||||||
? await createScheduledEventDiscord(guildId, payload, { accountId })
|
|
||||||
: await createScheduledEventDiscord(guildId, payload);
|
|
||||||
return jsonResult({ ok: true, event });
|
return jsonResult({ ok: true, event });
|
||||||
}
|
}
|
||||||
case "channelCreate": {
|
case "channelCreate": {
|
||||||
@@ -275,28 +229,15 @@ export async function handleDiscordGuildAction(
|
|||||||
const topic = readStringParam(params, "topic");
|
const topic = readStringParam(params, "topic");
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const nsfw = params.nsfw as boolean | undefined;
|
const nsfw = params.nsfw as boolean | undefined;
|
||||||
const channel = accountId
|
const channel = await createChannelDiscord({
|
||||||
? await createChannelDiscord(
|
guildId,
|
||||||
{
|
name,
|
||||||
guildId,
|
type: type ?? undefined,
|
||||||
name,
|
parentId: parentId ?? undefined,
|
||||||
type: type ?? undefined,
|
topic: topic ?? undefined,
|
||||||
parentId: parentId ?? undefined,
|
position: position ?? undefined,
|
||||||
topic: topic ?? undefined,
|
nsfw,
|
||||||
position: position ?? undefined,
|
});
|
||||||
nsfw,
|
|
||||||
},
|
|
||||||
{ accountId },
|
|
||||||
)
|
|
||||||
: await createChannelDiscord({
|
|
||||||
guildId,
|
|
||||||
name,
|
|
||||||
type: type ?? undefined,
|
|
||||||
parentId: parentId ?? undefined,
|
|
||||||
topic: topic ?? undefined,
|
|
||||||
position: position ?? undefined,
|
|
||||||
nsfw,
|
|
||||||
});
|
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelEdit": {
|
case "channelEdit": {
|
||||||
@@ -314,28 +255,15 @@ export async function handleDiscordGuildAction(
|
|||||||
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
|
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
|
||||||
integer: true,
|
integer: true,
|
||||||
});
|
});
|
||||||
const channel = accountId
|
const channel = await editChannelDiscord({
|
||||||
? await editChannelDiscord(
|
channelId,
|
||||||
{
|
name: name ?? undefined,
|
||||||
channelId,
|
topic: topic ?? undefined,
|
||||||
name: name ?? undefined,
|
position: position ?? undefined,
|
||||||
topic: topic ?? undefined,
|
parentId,
|
||||||
position: position ?? undefined,
|
nsfw,
|
||||||
parentId,
|
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
||||||
nsfw,
|
});
|
||||||
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
|
||||||
},
|
|
||||||
{ accountId },
|
|
||||||
)
|
|
||||||
: await editChannelDiscord({
|
|
||||||
channelId,
|
|
||||||
name: name ?? undefined,
|
|
||||||
topic: topic ?? undefined,
|
|
||||||
position: position ?? undefined,
|
|
||||||
parentId,
|
|
||||||
nsfw,
|
|
||||||
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
|
||||||
});
|
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelDelete": {
|
case "channelDelete": {
|
||||||
@@ -345,9 +273,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const result = accountId
|
const result = await deleteChannelDiscord(channelId);
|
||||||
? await deleteChannelDiscord(channelId, { accountId })
|
|
||||||
: await deleteChannelDiscord(channelId);
|
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
}
|
}
|
||||||
case "channelMove": {
|
case "channelMove": {
|
||||||
@@ -360,24 +286,12 @@ export async function handleDiscordGuildAction(
|
|||||||
});
|
});
|
||||||
const parentId = readParentIdParam(params);
|
const parentId = readParentIdParam(params);
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
if (accountId) {
|
await moveChannelDiscord({
|
||||||
await moveChannelDiscord(
|
guildId,
|
||||||
{
|
channelId,
|
||||||
guildId,
|
parentId,
|
||||||
channelId,
|
position: position ?? undefined,
|
||||||
parentId,
|
});
|
||||||
position: position ?? undefined,
|
|
||||||
},
|
|
||||||
{ accountId },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await moveChannelDiscord({
|
|
||||||
guildId,
|
|
||||||
channelId,
|
|
||||||
parentId,
|
|
||||||
position: position ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "categoryCreate": {
|
case "categoryCreate": {
|
||||||
@@ -387,22 +301,12 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", { required: true });
|
const guildId = readStringParam(params, "guildId", { required: true });
|
||||||
const name = readStringParam(params, "name", { required: true });
|
const name = readStringParam(params, "name", { required: true });
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const channel = accountId
|
const channel = await createChannelDiscord({
|
||||||
? await createChannelDiscord(
|
guildId,
|
||||||
{
|
name,
|
||||||
guildId,
|
type: 4,
|
||||||
name,
|
position: position ?? undefined,
|
||||||
type: 4,
|
});
|
||||||
position: position ?? undefined,
|
|
||||||
},
|
|
||||||
{ accountId },
|
|
||||||
)
|
|
||||||
: await createChannelDiscord({
|
|
||||||
guildId,
|
|
||||||
name,
|
|
||||||
type: 4,
|
|
||||||
position: position ?? undefined,
|
|
||||||
});
|
|
||||||
return jsonResult({ ok: true, category: channel });
|
return jsonResult({ ok: true, category: channel });
|
||||||
}
|
}
|
||||||
case "categoryEdit": {
|
case "categoryEdit": {
|
||||||
@@ -414,20 +318,11 @@ export async function handleDiscordGuildAction(
|
|||||||
});
|
});
|
||||||
const name = readStringParam(params, "name");
|
const name = readStringParam(params, "name");
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const channel = accountId
|
const channel = await editChannelDiscord({
|
||||||
? await editChannelDiscord(
|
channelId: categoryId,
|
||||||
{
|
name: name ?? undefined,
|
||||||
channelId: categoryId,
|
position: position ?? undefined,
|
||||||
name: name ?? undefined,
|
});
|
||||||
position: position ?? undefined,
|
|
||||||
},
|
|
||||||
{ accountId },
|
|
||||||
)
|
|
||||||
: await editChannelDiscord({
|
|
||||||
channelId: categoryId,
|
|
||||||
name: name ?? undefined,
|
|
||||||
position: position ?? undefined,
|
|
||||||
});
|
|
||||||
return jsonResult({ ok: true, category: channel });
|
return jsonResult({ ok: true, category: channel });
|
||||||
}
|
}
|
||||||
case "categoryDelete": {
|
case "categoryDelete": {
|
||||||
@@ -437,9 +332,7 @@ export async function handleDiscordGuildAction(
|
|||||||
const categoryId = readStringParam(params, "categoryId", {
|
const categoryId = readStringParam(params, "categoryId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const result = accountId
|
const result = await deleteChannelDiscord(categoryId);
|
||||||
? await deleteChannelDiscord(categoryId, { accountId })
|
|
||||||
: await deleteChannelDiscord(categoryId);
|
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
}
|
}
|
||||||
case "channelPermissionSet": {
|
case "channelPermissionSet": {
|
||||||
@@ -456,26 +349,13 @@ export async function handleDiscordGuildAction(
|
|||||||
const targetType = targetTypeRaw === "member" ? 1 : 0;
|
const targetType = targetTypeRaw === "member" ? 1 : 0;
|
||||||
const allow = readStringParam(params, "allow");
|
const allow = readStringParam(params, "allow");
|
||||||
const deny = readStringParam(params, "deny");
|
const deny = readStringParam(params, "deny");
|
||||||
if (accountId) {
|
await setChannelPermissionDiscord({
|
||||||
await setChannelPermissionDiscord(
|
channelId,
|
||||||
{
|
targetId,
|
||||||
channelId,
|
targetType,
|
||||||
targetId,
|
allow: allow ?? undefined,
|
||||||
targetType,
|
deny: deny ?? undefined,
|
||||||
allow: allow ?? undefined,
|
});
|
||||||
deny: deny ?? undefined,
|
|
||||||
},
|
|
||||||
{ accountId },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await setChannelPermissionDiscord({
|
|
||||||
channelId,
|
|
||||||
targetId,
|
|
||||||
targetType,
|
|
||||||
allow: allow ?? undefined,
|
|
||||||
deny: deny ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "channelPermissionRemove": {
|
case "channelPermissionRemove": {
|
||||||
@@ -486,11 +366,7 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const targetId = readStringParam(params, "targetId", { required: true });
|
const targetId = readStringParam(params, "targetId", { required: true });
|
||||||
if (accountId) {
|
await removeChannelPermissionDiscord(channelId, targetId);
|
||||||
await removeChannelPermissionDiscord(channelId, targetId, { accountId });
|
|
||||||
} else {
|
|
||||||
await removeChannelPermissionDiscord(channelId, targetId);
|
|
||||||
}
|
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user