Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5e3662a3 | ||
|
|
d3d6d80f4a | ||
|
|
a51948a528 |
No files matched your search
@@ -5,57 +5,6 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
install-check:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies (frozen)
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
checks:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
@@ -105,7 +54,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
@@ -136,7 +85,7 @@ jobs:
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
@@ -184,7 +133,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
@@ -215,7 +164,7 @@ jobs:
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
@@ -251,7 +200,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
@@ -276,7 +225,7 @@ jobs:
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- 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.
|
||||
- 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`.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
||||
|
||||
@@ -43,7 +41,6 @@
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
|
||||
+179
-56
@@ -1,62 +1,185 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.9
|
||||
|
||||
### Highlights
|
||||
- Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.
|
||||
- Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.
|
||||
- CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.
|
||||
- Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.
|
||||
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
|
||||
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
|
||||
|
||||
### Breaking
|
||||
- CLI: `clawdbot message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
||||
|
||||
### New Features and Changes
|
||||
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
|
||||
- Models/Auth: setup-token + token auth profiles; `clawdbot models auth order {get,set,clear}`; per-agent auth candidates in `/model status`; OAuth expiry checks in doctor/status.
|
||||
- Agent/System: claude-cli runner; `session_status` tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.
|
||||
- Commands: `/commands` list; `/models` alias; `/usage` alias; `/debug` runtime overrides + effective config view; `/config` chat updates + `/config get`; `config --section`.
|
||||
- CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.
|
||||
- CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global `--no-color`; lobster palette in onboarding/config.
|
||||
- Dev ergonomics: gateway `--dev/--reset` + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.
|
||||
- Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.
|
||||
- Config/Onboarding: inline env vars; OpenAI API key flow to shared `~/.clawdbot/.env`; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (`tui --message`); remove Bun runtime choice.
|
||||
- Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc
|
||||
- Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.
|
||||
- Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; `[[audio_as_voice]]` tag support (#490) — thanks @jarvis-medmatic.
|
||||
- Providers: Signal reactions + notifications with allowlist support.
|
||||
- Status/Usage: /status cost reporting + `/cost` lines; auth profile snippet; provider usage windows.
|
||||
- Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit `ui:install`.
|
||||
- TUI: agent picker + agents list RPC; improved status line.
|
||||
- Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.
|
||||
- Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.
|
||||
- Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.
|
||||
|
||||
### Fixes
|
||||
- Packaging: include MS Teams send module in npm tarball.
|
||||
- Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.
|
||||
- Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.
|
||||
- Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.
|
||||
- Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.
|
||||
- Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.
|
||||
- WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.
|
||||
- Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.
|
||||
- Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.
|
||||
- Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.
|
||||
- MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.
|
||||
- iMessage: isolate group-ish threads by chat_id.
|
||||
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
|
||||
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
|
||||
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
||||
|
||||
### Maintenance
|
||||
- Dependencies: bump pi-* stack to 0.42.2.
|
||||
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
||||
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
||||
## Unreleased
|
||||
|
||||
- Models/Auth: add OpenCode Zen (multi-model proxy) onboarding. (#623) — thanks @magimetal
|
||||
- WhatsApp: refactor vCard parsing helper and improve empty contact card summaries. (#624) — thanks @steipete
|
||||
- WhatsApp: include phone numbers when multiple contacts are shared. (#625) — thanks @mahmoudashraf93
|
||||
- Agents: warn on small context windows (<32k) and block unusable ones (<16k). — thanks @steipete
|
||||
- Pairing: cap pending DM pairing requests at 3 per provider and avoid pairing replies for outbound DMs. — thanks @steipete
|
||||
- macOS: replace relay smoke test with version check in packaging script. (#615) — thanks @YuriNachos
|
||||
- macOS: avoid clearing Launch at Login during app initialization. (#607) — thanks @wes-davis
|
||||
- Onboarding: skip systemd checks/daemon installs when systemd user services are unavailable; add onboarding flags to skip flow steps and stabilize Docker E2E. (#573) — thanks @steipete
|
||||
- Onboarding: QuickStart provider picker uses single-select to avoid accidental Telegram token prompts when choosing WhatsApp. (#485) — thanks @frankstallone
|
||||
- macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman
|
||||
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
|
||||
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
|
||||
- Control UI: persist per-session verbose off and hide tool cards unless verbose is on. (#262) — thanks @steipete
|
||||
- Gateway: centralize verbose overrides and gate tool stream events at the server. (#262) — thanks @steipete
|
||||
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
|
||||
- Sandbox: allow `session_status` tool in sandboxed sessions by default. — thanks @steipete
|
||||
- CLI: add `clawdbot config --section <name>` to jump straight into a wizard section (repeatable).
|
||||
- Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig
|
||||
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
|
||||
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
|
||||
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
|
||||
- Slack: configurable reply threading (`slack.replyToMode`) + proper mrkdwn formatting for outbound messages. (#464) — thanks @austinm911
|
||||
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
|
||||
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
|
||||
- Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete
|
||||
- Commands: accept /models as an alias for /model.
|
||||
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
|
||||
- Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75
|
||||
- Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete
|
||||
- Agents: avoid base-to-string error stringification in model fallback. (#604) — thanks @steipete
|
||||
- Agents: `sessions_spawn` inherits the requester's provider for child runs (avoid WhatsApp fallback). (#528) — thanks @rlmestre
|
||||
- Agents: sub-agent context now injects only AGENTS.md + TOOLS.md (omits identity/user/soul/heartbeat/bootstrap). — thanks @steipete
|
||||
- Gateway/CLI: harden agent provider routing + validation (Slack/MS Teams + aliases). (follow-up #528) — thanks @steipete
|
||||
- Agents: treat billing/insufficient-credits errors as failover-worthy so model fallbacks kick in. (#486) — thanks @steipete
|
||||
- Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete
|
||||
- Commands: harden slash command registry and list text-only commands in `/commands`.
|
||||
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
|
||||
- Telegram: keep streamMode draft-only; avoid forcing block streaming. (#619) — thanks @rubyrunsstuff
|
||||
- Debugging: add raw model stream logging flags and document gateway watch mode.
|
||||
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
|
||||
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
|
||||
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
|
||||
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
|
||||
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
|
||||
- Hooks: normalize hook agent providers (aliases + msteams support).
|
||||
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
|
||||
- WhatsApp: improve "no active web listener" errors (include account + relink hint). (#612) — thanks @YuriNachos
|
||||
- WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott
|
||||
- WhatsApp: resolve @lid inbound senders via auth-dir mapping fallback + shared resolver. (#365)
|
||||
- WhatsApp: treat shared contact cards as inbound messages (prefer vCard FN). (#622) — thanks @mahmoudashraf93
|
||||
- iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann
|
||||
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
|
||||
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
|
||||
- Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
|
||||
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
|
||||
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
|
||||
- Config: write `clawdbot.json` atomically (temp file + replace) and keep a best-effort `.bak` backup.
|
||||
- Agent: enable adaptive context pruning by default for tool-result trimming.
|
||||
- Agent: drop empty error assistant messages when sanitizing session history. (#591) — thanks @steipete
|
||||
- Agent: inject eligible skills list into the system prompt so bundled skills load from their actual locations. (#551) — thanks @gabriel-trigo
|
||||
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
|
||||
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
|
||||
- Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
|
||||
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
|
||||
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
|
||||
- WhatsApp: make inbound media size cap configurable (default 50 MB). (#505) — thanks @koala73
|
||||
- Doctor: run legacy state migrations in non-interactive mode without prompts.
|
||||
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
|
||||
- Cron: enqueue main-session system events under the resolved main session key. (#510)
|
||||
- Mobile: centralize main session key normalization for iOS/Android runtime helpers. — thanks @steipete
|
||||
- Chat UI: stop pinning hardcoded `main` session in the recent list; prefer active session if missing. — thanks @steipete
|
||||
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
|
||||
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
|
||||
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
|
||||
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
|
||||
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
|
||||
- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
|
||||
- Agents: sanitize Cloud Code Assist tool call IDs and detect format/quota errors for failover. (#544) — thanks @jeffersonwarrior
|
||||
- Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj
|
||||
- Agents: require `raw` for gateway `config.apply` tool calls while keeping schema 2020-12 compatible. (#566) — thanks @sircrumpet
|
||||
- Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete
|
||||
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
|
||||
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
|
||||
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
|
||||
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
|
||||
- Auto-reply: relax reply tag parsing to allow whitespace. (#560) — thanks @mcinteerj
|
||||
- Auto-reply: add per-provider block streaming toggles and coalesce streamed blocks to reduce line spam. (#536) — thanks @mcinteerj
|
||||
- Auto-reply: suppress `<think>` leakage in block streaming and emit `/reasoning` as a separate `Reasoning:` message. (#614) — thanks @zknicker
|
||||
- Auto-reply: default block streaming off for non-Telegram providers unless explicitly enabled, and avoid splitting on forced flushes below max.
|
||||
- Auto-reply: raise default coalesce minChars for Signal/Slack/Discord and clarify streaming vs draft streaming in docs.
|
||||
- Auto-reply: default block streaming coalesce idle to 1s to reduce tiny chunks. — thanks @steipete
|
||||
- Auto-reply: fix /status usage summary filtering for the active provider.
|
||||
- Auto-reply: deduplicate followup queue entries using message id/routing to avoid duplicate replies. (#600) — thanks @samratjha96
|
||||
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
|
||||
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
|
||||
- Status: show active auth profile and key snippet in /status.
|
||||
- Status: show provider usage windows when auth uses token-based OAuth (e.g. Claude setup-token).
|
||||
- Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
|
||||
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
|
||||
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
|
||||
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
|
||||
- Control UI: logs tab opens at the newest entries (bottom).
|
||||
- Control UI: default to relative paths for control UI assets. (#569) — thanks @bjesuiter
|
||||
- Control UI: add Docs link, remove chat composer divider, and add New session button.
|
||||
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
|
||||
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
|
||||
- Sessions: clarify `sessions_send` delivery semantics, log announce failures, and enforce Discord request timeouts. (#507) — thanks @steipete
|
||||
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
|
||||
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
|
||||
- Control UI: refactor chat layout with tool sidebar, grouped messages, and nav improvements. (#475) — thanks @rahthakor
|
||||
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
|
||||
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
||||
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
|
||||
- Telegram: add inline keyboard buttons (capability-gated) and route callback query payloads as messages. (#491) — thanks @azade-c
|
||||
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
|
||||
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
|
||||
- Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard
|
||||
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
|
||||
- Signal: ignore reaction-only messages so they don't surface as unknown media. (#616) — thanks @neist
|
||||
- Signal: add reaction notifications with allowlist support. — thanks @steipete
|
||||
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
|
||||
- Onboarding: tighten QuickStart hint copy for configuring later.
|
||||
- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
|
||||
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
|
||||
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
|
||||
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
||||
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
|
||||
- Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
|
||||
- Commands: add /debug for runtime config overrides (memory-only).
|
||||
- Daemon runtime: remove Bun from selection options.
|
||||
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
|
||||
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
|
||||
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
|
||||
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
|
||||
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
|
||||
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
|
||||
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
|
||||
- Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
|
||||
- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
|
||||
- Docs: add Ansible installation guide. (#545) — thanks @pasogott
|
||||
- Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
|
||||
- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
|
||||
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
|
||||
- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
|
||||
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
|
||||
- TUI: stop overriding agent timeout so config defaults apply; warn on invalid `--timeout-ms`. (#549)
|
||||
- Status: show Verbose/Elevated only when enabled.
|
||||
- Status: filter usage summary to the active model provider.
|
||||
- Status: map model providers to usage sources so unrelated usage doesn’t appear.
|
||||
- Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`.
|
||||
- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
|
||||
- Commands: keep multi-directive messages from clearing directive handling.
|
||||
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
|
||||
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
|
||||
- Commands: return /status in directive-only multi-line messages.
|
||||
- Models: fall back to configured models when the provider catalog is unavailable.
|
||||
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
|
||||
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
|
||||
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
|
||||
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
|
||||
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
|
||||
- Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
|
||||
- Gateway/Discovery: include `gatewayPort`/`sshPort`/`cliPath` in wide-area Bonjour records, and add a tailnet DNS fallback for `gateway discover` when split DNS isn’t configured. — thanks @steipete
|
||||
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
|
||||
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
|
||||
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md), and reset wipes config/creds/sessions/workspace (subcommand --dev no longer collides with global --dev profile). — thanks @steipete
|
||||
- Configure: stop prompting to open the Control UI (still shown in onboarding). — thanks @steipete
|
||||
- Configure: add wizard mode to remove a provider config block. — thanks @steipete
|
||||
- Onboarding/TUI: prompt to start TUI (best option) when BOOTSTRAP.md exists and add `tui --message` to auto-send the first prompt. — thanks @steipete
|
||||
- Telegram: suppress grammY getUpdates stack traces; log concise retry message instead. — thanks @steipete
|
||||
- Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
|
||||
- Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
|
||||
- Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
|
||||
- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
|
||||
- Docs: split CLI install commands into separate code blocks. (#601) — thanks @martinpucik
|
||||
- WhatsApp: record outbound provider activity using the active account id. (#537) — thanks @Nachx639
|
||||
- Discord: add gateway HELLO timeout to detect zombie connections. (#608) — thanks @NicholasSpisak
|
||||
- Docker: cache dependency layer for faster rebuilds. (#605) — thanks @zknicker
|
||||
|
||||
## 2026.1.8
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ RUN apt-get update \
|
||||
jq \
|
||||
novnc \
|
||||
python3 \
|
||||
socat \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb \
|
||||
|
||||
@@ -153,7 +153,6 @@ Chat messages support `/...` commands (text and native). See [/tools/slash-comma
|
||||
|
||||
Highlights:
|
||||
- `/status` for quick diagnostics.
|
||||
- `/config` for persisted config changes.
|
||||
- `/debug` for runtime-only config overrides (memory, not disk).
|
||||
|
||||
## Setup + onboarding
|
||||
|
||||
@@ -58,8 +58,6 @@ If you have both an OAuth profile and an API key profile for the same provider,
|
||||
|
||||
When a profile fails due to auth/rate‑limit errors (or a timeout that looks
|
||||
like rate limiting), Clawdbot marks it in cooldown and moves to the next profile.
|
||||
Format/invalid‑request errors (for example Cloud Code Assist tool call ID
|
||||
validation failures) are treated as failover‑worthy and use the same cooldowns.
|
||||
|
||||
Cooldowns use exponential backoff:
|
||||
- 1 minute
|
||||
|
||||
@@ -33,9 +33,6 @@ Related:
|
||||
Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize
|
||||
to `zai/*`.
|
||||
|
||||
Provider configuration examples (including OpenCode Zen) live in
|
||||
[/gateway/configuration](/gateway/configuration#opencode-zen-multi-model-proxy).
|
||||
|
||||
## “Model is not allowed” (and why replies stop)
|
||||
|
||||
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for
|
||||
|
||||
+4
-11
@@ -54,11 +54,11 @@ debugging. There are **two** `--dev` flags:
|
||||
- **`gateway --dev`: tells the Gateway to auto-create a default config +
|
||||
workspace** when missing (and skip BOOTSTRAP.md).
|
||||
|
||||
Recommended flow (dev profile + dev bootstrap):
|
||||
Recommended flow:
|
||||
|
||||
```bash
|
||||
pnpm gateway:dev
|
||||
CLAWDBOT_PROFILE=dev pnpm clawdbot tui
|
||||
pnpm clawdbot --dev gateway --dev
|
||||
pnpm clawdbot --dev tui
|
||||
```
|
||||
|
||||
What this does:
|
||||
@@ -80,14 +80,7 @@ What this does:
|
||||
Reset flow (fresh start):
|
||||
|
||||
```bash
|
||||
pnpm gateway:dev:reset
|
||||
```
|
||||
|
||||
Note: `--dev` is a **global** profile flag and gets eaten by some runners.
|
||||
If you need to spell it out, use the env var form:
|
||||
|
||||
```bash
|
||||
CLAWDBOT_PROFILE=dev pnpm clawdbot gateway --dev --reset
|
||||
pnpm clawdbot --dev gateway --dev --reset
|
||||
```
|
||||
|
||||
`--reset` wipes config, credentials, sessions, and the dev workspace (using
|
||||
|
||||
@@ -718,7 +718,6 @@
|
||||
{
|
||||
"group": "Reference & Templates",
|
||||
"pages": [
|
||||
"testing",
|
||||
"scripts",
|
||||
"reference/rpc",
|
||||
"reference/device-models",
|
||||
|
||||
@@ -948,8 +948,7 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context
|
||||
messages: {
|
||||
responsePrefix: "🦞", // or "auto"
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "group-mentions",
|
||||
removeAckAfterReply: false
|
||||
ackReactionScope: "group-mentions"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -976,9 +975,6 @@ active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
|
||||
- `direct`: direct messages only
|
||||
- `all`: all messages
|
||||
|
||||
`removeAckAfterReply` removes the bot’s ack reaction after a reply is sent
|
||||
(Slack/Discord/Telegram only). Default: `false`.
|
||||
|
||||
### `talk`
|
||||
|
||||
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
|
||||
@@ -1354,9 +1350,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
||||
vncPort: 5900,
|
||||
noVncPort: 6080,
|
||||
headless: false,
|
||||
enableNoVnc: true,
|
||||
autoStart: true,
|
||||
autoStartTimeoutMs: 12000
|
||||
enableNoVnc: true
|
||||
},
|
||||
prune: {
|
||||
idleHours: 24, // 0 disables idle pruning
|
||||
@@ -1445,47 +1439,6 @@ Select the model via `agents.defaults.model.primary` (provider/model).
|
||||
}
|
||||
```
|
||||
|
||||
### OpenCode Zen (multi-model proxy)
|
||||
|
||||
OpenCode Zen is an OpenAI-compatible proxy at `https://opencode.ai/zen/v1`. Get an API key at https://opencode.ai/auth and set `OPENCODE_ZEN_API_KEY`.
|
||||
|
||||
Notes:
|
||||
- Model refs use `opencode-zen/<modelId>` (example: `opencode-zen/claude-opus-4-5`).
|
||||
- If you enable an allowlist via `agents.defaults.models`, add each model you plan to use.
|
||||
- Shortcut: `clawdbot onboard --auth-choice opencode-zen`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "opencode-zen/claude-opus-4-5" },
|
||||
models: { "opencode-zen/claude-opus-4-5": { alias: "Opus" } }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"opencode-zen": {
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
apiKey: "${OPENCODE_ZEN_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 32000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Z.AI (GLM-4.7) — provider alias support
|
||||
|
||||
Z.AI models are available via the built-in `zai` provider. Set `ZAI_API_KEY`
|
||||
|
||||
@@ -19,8 +19,6 @@ and process access when the model does something dumb.
|
||||
## What gets sandboxed
|
||||
- Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.).
|
||||
- Optional sandboxed browser (`agents.defaults.sandbox.browser`).
|
||||
- By default, the sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it.
|
||||
Configure via `agents.defaults.sandbox.browser.autoStart` and `agents.defaults.sandbox.browser.autoStartTimeoutMs`.
|
||||
|
||||
Not sandboxed:
|
||||
- The Gateway process itself.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
summary: "Run Clawdbot Gateway 24/7 on a cheap Hetzner VPS (Docker) with durable state and baked-in binaries"
|
||||
summary: "Run Clawdbot Gateway on Hetzner (Docker + VPS) with durable state and baked-in binaries"
|
||||
read_when:
|
||||
- You want Clawdbot running 24/7 on a cloud VPS (not your laptop)
|
||||
- You want a production-grade, always-on Gateway on your own VPS
|
||||
- You want full control over persistence, binaries, and restart behavior
|
||||
- You are running Clawdbot in Docker on Hetzner or a similar provider
|
||||
@@ -12,17 +11,6 @@ read_when:
|
||||
## Goal
|
||||
Run a persistent Clawdbot Gateway on a Hetzner VPS using Docker, with durable state, baked-in binaries, and safe restart behavior.
|
||||
|
||||
If you want “Clawdbot 24/7 for ~$5”, this is the simplest reliable setup.
|
||||
Hetzner pricing changes; pick the smallest Debian/Ubuntu VPS and scale up if you hit OOMs.
|
||||
|
||||
## What are we doing (simple terms)?
|
||||
|
||||
- Rent a small Linux server (Hetzner VPS)
|
||||
- Install Docker (isolated app runtime)
|
||||
- Start the Clawdbot Gateway in Docker
|
||||
- Persist `~/.clawdbot` + `~/clawd` on the host (survives restarts/rebuilds)
|
||||
- Access the Control UI from your laptop via an SSH tunnel
|
||||
|
||||
The Gateway can be accessed via:
|
||||
- SSH port forwarding from your laptop
|
||||
- Direct port exposure if you manage firewalling and tokens yourself
|
||||
@@ -50,8 +38,6 @@ For the generic Docker flow, see [Docker](/install/docker).
|
||||
|
||||
- Hetzner VPS with root access
|
||||
- SSH access from your laptop
|
||||
- Basic comfort with SSH + copy/paste
|
||||
- ~20 minutes
|
||||
- Docker and Docker Compose
|
||||
- Model auth credentials
|
||||
- Optional provider credentials
|
||||
@@ -138,12 +124,6 @@ GOG_KEYRING_PASSWORD=change-me-now
|
||||
XDG_CONFIG_HOME=/home/node/.clawdbot
|
||||
```
|
||||
|
||||
Generate strong secrets:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**Do not commit this file.**
|
||||
|
||||
---
|
||||
@@ -175,14 +155,9 @@ services:
|
||||
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
||||
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
||||
ports:
|
||||
# Recommended: keep the Gateway loopback-only on the VPS; access via SSH tunnel.
|
||||
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
|
||||
- "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
|
||||
|
||||
# Optional: only if you run iOS/Android nodes against this VPS.
|
||||
# If you expose these publicly, read /gateway/security and firewall accordingly.
|
||||
# - "${CLAWDBOT_BRIDGE_PORT}:18790"
|
||||
# - "18793:18793"
|
||||
- "${CLAWDBOT_GATEWAY_PORT}:18789"
|
||||
- "${CLAWDBOT_BRIDGE_PORT}:18790"
|
||||
- "18793:18793"
|
||||
command:
|
||||
[
|
||||
"node",
|
||||
@@ -252,8 +227,6 @@ RUN pnpm build
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node","dist/index.js"]
|
||||
```
|
||||
|
||||
|
||||
@@ -225,8 +225,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
||||
```
|
||||
|
||||
Ack reactions are controlled globally via `messages.ackReaction` +
|
||||
`messages.ackReactionScope`. Use `messages.removeAckAfterReply` to clear the
|
||||
ack reaction after the bot replies.
|
||||
`messages.ackReactionScope`.
|
||||
|
||||
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
||||
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
|
||||
|
||||
@@ -192,8 +192,7 @@ Tokens can also be supplied via env vars:
|
||||
- `SLACK_APP_TOKEN`
|
||||
|
||||
Ack reactions are controlled globally via `messages.ackReaction` +
|
||||
`messages.ackReactionScope`. Use `messages.removeAckAfterReply` to clear the
|
||||
ack reaction after the bot replies.
|
||||
`messages.ackReactionScope`.
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `slack.textChunkLimit` (default 4000).
|
||||
|
||||
@@ -287,4 +287,4 @@ Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
|
||||
- `messages.groupChat.mentionPatterns` (global fallback).
|
||||
- `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior).
|
||||
- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`, `messages.removeAckAfterReply`.
|
||||
- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`.
|
||||
@@ -5,8 +5,6 @@ read_when:
|
||||
---
|
||||
# Tests
|
||||
|
||||
- Full testing kit (suites, live, Docker): [Testing](/testing)
|
||||
|
||||
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
|
||||
- `pnpm test:coverage`: Runs Vitest with V8 coverage. Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
|
||||
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing).
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
summary: "Testing kit: unit/e2e/live suites, Docker runners, and what each test covers"
|
||||
read_when:
|
||||
- Running tests locally or in CI
|
||||
- Adding regressions for model/provider bugs
|
||||
- Debugging gateway + agent behavior
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
Clawdbot has three Vitest suites (unit, e2e, live) plus a couple Docker helpers for “run with my real keys” smoke checks.
|
||||
|
||||
## Quick start
|
||||
|
||||
- Full gate (what we expect before push): `pnpm lint && pnpm build && pnpm test`
|
||||
- Coverage gate: `pnpm test:coverage`
|
||||
- E2E suite: `pnpm test:e2e`
|
||||
- Live suite (opt-in, Clawdbot only): `CLAWDBOT_LIVE_TEST=1 pnpm test:live`
|
||||
- Live suite (opt-in, includes provider live tests too): `LIVE=1 pnpm test:live`
|
||||
|
||||
## Test suites (what runs where)
|
||||
|
||||
### Unit / integration (default)
|
||||
|
||||
- Command: `pnpm test`
|
||||
- Config: `vitest.config.ts`
|
||||
- Files: `src/**/*.test.ts`
|
||||
- Scope: pure unit tests + in-process integration tests (gateway server auth, routing, tooling, parsing, config).
|
||||
|
||||
### E2E (gateway smoke)
|
||||
|
||||
- Command: `pnpm test:e2e`
|
||||
- Config: `vitest.e2e.config.ts`
|
||||
- Files: `src/**/*.e2e.test.ts`
|
||||
- Scope: multi-instance gateway end-to-end behavior (WebSocket/HTTP/node pairing), heavier networking surface.
|
||||
|
||||
### Live (real providers + real models)
|
||||
|
||||
- Command: `pnpm test:live`
|
||||
- Config: `vitest.live.config.ts`
|
||||
- Files: `src/**/*.live.test.ts`
|
||||
- Default: **skipped** unless `CLAWDBOT_LIVE_TEST=1` or `LIVE=1`
|
||||
- Scope: “does this provider/model actually work today with real creds”.
|
||||
|
||||
## Live: model smoke (profile keys)
|
||||
|
||||
Two layers:
|
||||
|
||||
1. Direct model completion (no gateway):
|
||||
- Test: `src/agents/models.profiles.live.test.ts`
|
||||
- Goal: enumerate discovered models, use `getApiKeyForModel` to pick ones you have creds for, then run a small completion.
|
||||
- Selection:
|
||||
- `CLAWDBOT_LIVE_ALL_MODELS=1` (required to run the suite)
|
||||
- `CLAWDBOT_LIVE_MODELS=all` or comma allowlist (`openai/gpt-5.2,anthropic/claude-opus-4-5,...`)
|
||||
- `CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not ad-hoc env).
|
||||
- Regression hook: OpenAI Responses tool-only → follow-up path (the `reasoning` replay class) is covered here.
|
||||
|
||||
2. Gateway + dev agent smoke (what “@clawdbot” actually does):
|
||||
- Test: `src/gateway/gateway-models.profiles.live.test.ts`
|
||||
- Goal: spin up an in-process gateway, create/patch a `agent:dev:*` session, iterate models-with-keys, and assert “meaningful” responses.
|
||||
- Covers providers present in your `models.json`/config (e.g. OpenAI, Anthropic, Google Gemini, `google-antigravity`, etc.) as long as a key/profile is available.
|
||||
- Selection:
|
||||
- `CLAWDBOT_LIVE_GATEWAY=1`
|
||||
- `CLAWDBOT_LIVE_GATEWAY_ALL_MODELS=1` (scan all discovered models with available keys)
|
||||
- `CLAWDBOT_LIVE_GATEWAY_MODELS=all` or comma allowlist
|
||||
- Extra regression: for OpenAI Responses/Codex Responses models, force a tool-call-only turn followed by a user question (the exact failure mode that produced `400 … reasoning … required following item`).
|
||||
|
||||
## Credentials (never commit)
|
||||
|
||||
Live tests discover credentials the same way the CLI does:
|
||||
|
||||
- Profile store: `~/.clawdbot/credentials/` (preferred; what “profile keys” means in the tests)
|
||||
- Config: `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`)
|
||||
|
||||
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
|
||||
|
||||
## Docker runners (optional “works in Linux” checks)
|
||||
|
||||
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
|
||||
|
||||
- Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`)
|
||||
- Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`)
|
||||
|
||||
Useful env vars:
|
||||
|
||||
- `CLAWDBOT_CONFIG_DIR=...` (default: `~/.clawdbot`) mounted to `/home/node/.clawdbot`
|
||||
- `CLAWDBOT_WORKSPACE_DIR=...` (default: `~/clawd`) mounted to `/home/node/clawd`
|
||||
- `CLAWDBOT_PROFILE_FILE=...` (default: `~/.profile`) mounted to `/home/node/.profile` and sourced before running tests
|
||||
- `CLAWDBOT_LIVE_GATEWAY_MODELS=...` / `CLAWDBOT_LIVE_MODELS=...` to narrow the run
|
||||
- `CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not env)
|
||||
|
||||
## Docs sanity
|
||||
|
||||
Run docs checks after doc edits: `pnpm docs:list`.
|
||||
|
||||
## Offline regression (CI-safe)
|
||||
|
||||
- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts`
|
||||
@@ -39,7 +39,6 @@ Text + native (when enabled):
|
||||
- `/status`
|
||||
- `/status` (show current status; includes a short usage line when available)
|
||||
- `/usage` (alias: `/status`)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
|
||||
- `/cost on|off` (toggle per-response usage line)
|
||||
- `/stop`
|
||||
@@ -83,24 +82,6 @@ Notes:
|
||||
- Overrides apply immediately to new config reads, but do **not** write to `clawdbot.json`.
|
||||
- Use `/debug reset` to clear all overrides and return to the on-disk config.
|
||||
|
||||
## Config updates
|
||||
|
||||
`/config` writes to your on-disk config (`clawdbot.json`). Owner-only.
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
/config show
|
||||
/config show messages.responsePrefix
|
||||
/config get messages.responsePrefix
|
||||
/config set messages.responsePrefix="[clawdbot]"
|
||||
/config unset messages.responsePrefix
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Config is validated before write; invalid changes are rejected.
|
||||
- `/config` updates persist across restarts.
|
||||
|
||||
## Surface notes
|
||||
|
||||
- **Text commands** run in the normal chat session (DMs share `main`, groups have their own session).
|
||||
|
||||
+15
-18
@@ -25,7 +25,6 @@
|
||||
"dist/infra/**",
|
||||
"dist/macos/**",
|
||||
"dist/media/**",
|
||||
"dist/msteams/**",
|
||||
"dist/process/**",
|
||||
"dist/sessions/**",
|
||||
"dist/providers/**",
|
||||
@@ -49,8 +48,7 @@
|
||||
"dist/utils/**",
|
||||
"dist/logging/**",
|
||||
"dist/markdown/**",
|
||||
"dist/pairing/**",
|
||||
"dist/whatsapp/**"
|
||||
"dist/pairing/**"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/entry.ts",
|
||||
@@ -67,10 +65,6 @@
|
||||
"start": "tsx src/entry.ts",
|
||||
"clawdbot": "tsx src/entry.ts",
|
||||
"gateway:watch": "tsx watch src/entry.ts gateway --force",
|
||||
"gateway:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts gateway --dev",
|
||||
"gateway:dev:reset": "CLAWDBOT_PROFILE=dev tsx src/entry.ts gateway --reset",
|
||||
"tui": "tsx src/entry.ts tui",
|
||||
"tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui",
|
||||
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
|
||||
"ios:gen": "cd apps/ios && xcodegen generate",
|
||||
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
||||
@@ -98,9 +92,6 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:live": "vitest run --config vitest.live.config.ts",
|
||||
"test:docker:onboard": "bash scripts/e2e/onboard-docker.sh",
|
||||
"test:docker:live-models": "bash scripts/test-live-models-docker.sh",
|
||||
"test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh",
|
||||
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
|
||||
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
|
||||
"protocol:gen": "tsx scripts/protocol-gen.ts",
|
||||
@@ -121,10 +112,10 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@mariozechner/pi-agent-core": "^0.42.2",
|
||||
"@mariozechner/pi-ai": "^0.42.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.42.2",
|
||||
"@mariozechner/pi-tui": "^0.42.2",
|
||||
"@mariozechner/pi-agent-core": "^0.42.1",
|
||||
"@mariozechner/pi-ai": "^0.42.1",
|
||||
"@mariozechner/pi-coding-agent": "^0.42.1",
|
||||
"@mariozechner/pi-tui": "^0.42.1",
|
||||
"@microsoft/agents-hosting": "^1.1.1",
|
||||
"@microsoft/agents-hosting-express": "^1.1.1",
|
||||
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
||||
@@ -192,12 +183,10 @@
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@buape/carbon@0.0.0-beta-20260109194934": "patches/@buape__carbon@0.0.0-beta-20260109194934.patch",
|
||||
"@buape/carbon": "patches/@buape__carbon.patch",
|
||||
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
|
||||
"@mariozechner/pi-ai@0.42.2": "patches/@mariozechner__pi-ai@0.42.2.patch",
|
||||
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
|
||||
"playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch",
|
||||
"qrcode-terminal": "patches/qrcode-terminal.patch"
|
||||
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
@@ -231,5 +220,13 @@
|
||||
"apps/macos/.build/**",
|
||||
"dist/Clawdbot.app/**"
|
||||
]
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@buape/carbon": "patches/@buape__carbon.patch",
|
||||
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
|
||||
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
|
||||
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
|
||||
"qrcode-terminal": "patches/qrcode-terminal.patch",
|
||||
"playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch"
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -1,8 +1,6 @@
|
||||
diff --git a/dist/src/classes/RequestClient.js b/dist/src/classes/RequestClient.js
|
||||
index 4a3357a3a71e993e49dbf4511be7f39aeec03755..cae09147c4016ff15ee09c7a0c679a6cd6e374e0 100644
|
||||
--- a/dist/src/classes/RequestClient.js
|
||||
+++ b/dist/src/classes/RequestClient.js
|
||||
@@ -118,6 +118,9 @@ export class RequestClient {
|
||||
@@ -118,6 +118,9 @@
|
||||
}
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
@@ -12,7 +10,7 @@ index 4a3357a3a71e993e49dbf4511be7f39aeec03755..cae09147c4016ff15ee09c7a0c679a6c
|
||||
let body;
|
||||
if (data?.body &&
|
||||
typeof data.body === "object" &&
|
||||
@@ -178,12 +181,26 @@ export class RequestClient {
|
||||
@@ -178,12 +181,26 @@
|
||||
body = JSON.stringify(data.body);
|
||||
}
|
||||
}
|
||||
@@ -45,3 +43,11 @@ index 4a3357a3a71e993e49dbf4511be7f39aeec03755..cae09147c4016ff15ee09c7a0c679a6c
|
||||
let rawBody = "";
|
||||
let parsedBody;
|
||||
try {
|
||||
@@ -405,4 +422,4 @@
|
||||
}
|
||||
}
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(ms, 0)));
|
||||
-//# sourceMappingURL=RequestClient.js.map
|
||||
\ No newline at end of file
|
||||
+//# sourceMappingURL=RequestClient.js.map
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
|
||||
index 0000000..1111111 100644
|
||||
--- a/dist/providers/google-gemini-cli.js
|
||||
+++ b/dist/providers/google-gemini-cli.js
|
||||
@@ -248,6 +248,12 @@ async function* streamGeminiCli(model, context, credentials, options) {
|
||||
break; // Success, exit retry loop
|
||||
}
|
||||
const errorText = await response.text();
|
||||
+ // PATCH: Fail immediately on 429 for Antigravity to let caller rotate accounts.
|
||||
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
|
||||
+ if (isAntigravity && response.status === 429) {
|
||||
+ console.log(`[pi-ai] 429 rate limit - failing fast to rotate account`);
|
||||
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
|
||||
+ }
|
||||
// Check if retryable
|
||||
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
||||
// Use server-provided delay or exponential backoff
|
||||
@@ -1,57 +0,0 @@
|
||||
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
|
||||
index 93aa26c395e9bd0df64376408a13d15ee9e7cce7..41a439e5fc370038a5febef9e8f021ee279cf8aa 100644
|
||||
--- a/dist/providers/google-gemini-cli.js
|
||||
+++ b/dist/providers/google-gemini-cli.js
|
||||
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
|
||||
break; // Success, exit retry loop
|
||||
}
|
||||
const errorText = await response.text();
|
||||
+ // Fail immediately on 429 for Antigravity to let callers rotate accounts.
|
||||
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
|
||||
+ if (isAntigravity && response.status === 429) {
|
||||
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
|
||||
+ }
|
||||
// Check if retryable
|
||||
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
||||
// Use server-provided delay or exponential backoff
|
||||
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
|
||||
index 188a8294f26fe1bfe3fb298a7f58e4d8eaf2a529..3fd8027edafdad4ca364af53f0a1811139705b21 100644
|
||||
--- a/dist/providers/openai-codex-responses.js
|
||||
+++ b/dist/providers/openai-codex-responses.js
|
||||
@@ -433,9 +433,15 @@ function convertMessages(model, context) {
|
||||
}
|
||||
else if (msg.role === "assistant") {
|
||||
const output = [];
|
||||
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
|
||||
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
|
||||
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
|
||||
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "thinking" && msg.stopReason !== "error") {
|
||||
if (block.thinkingSignature) {
|
||||
+ if (!hasTextBlock)
|
||||
+ continue;
|
||||
const reasoningItem = JSON.parse(block.thinkingSignature);
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
|
||||
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..0bf46bfb4a6fac5a0304652e42566b2c991bab48 100644
|
||||
--- a/dist/providers/openai-responses.js
|
||||
+++ b/dist/providers/openai-responses.js
|
||||
@@ -396,10 +396,16 @@ function convertMessages(model, context) {
|
||||
}
|
||||
else if (msg.role === "assistant") {
|
||||
const output = [];
|
||||
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
|
||||
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
|
||||
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
|
||||
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
|
||||
for (const block of msg.content) {
|
||||
// Do not submit thinking blocks if the completion had an error (i.e. abort)
|
||||
if (block.type === "thinking" && msg.stopReason !== "error") {
|
||||
if (block.thinkingSignature) {
|
||||
+ if (!hasTextBlock)
|
||||
+ continue;
|
||||
const reasoningItem = JSON.parse(block.thinkingSignature);
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
|
||||
index 93aa26c395e9bd0df64376408a13d15ee9e7cce7..41a439e5fc370038a5febef9e8f021ee279cf8aa 100644
|
||||
--- a/dist/providers/google-gemini-cli.js
|
||||
+++ b/dist/providers/google-gemini-cli.js
|
||||
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
|
||||
break; // Success, exit retry loop
|
||||
}
|
||||
const errorText = await response.text();
|
||||
+ // Fail immediately on 429 for Antigravity to let callers rotate accounts.
|
||||
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
|
||||
+ if (isAntigravity && response.status === 429) {
|
||||
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
|
||||
+ }
|
||||
// Check if retryable
|
||||
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
||||
// Use server-provided delay or exponential backoff
|
||||
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
|
||||
index 188a8294f26fe1bfe3fb298a7f58e4d8eaf2a529..3fd8027edafdad4ca364af53f0a1811139705b21 100644
|
||||
--- a/dist/providers/openai-codex-responses.js
|
||||
+++ b/dist/providers/openai-codex-responses.js
|
||||
@@ -433,9 +433,15 @@ function convertMessages(model, context) {
|
||||
}
|
||||
else if (msg.role === "assistant") {
|
||||
const output = [];
|
||||
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
|
||||
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
|
||||
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
|
||||
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "thinking" && msg.stopReason !== "error") {
|
||||
if (block.thinkingSignature) {
|
||||
+ if (!hasTextBlock)
|
||||
+ continue;
|
||||
const reasoningItem = JSON.parse(block.thinkingSignature);
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
|
||||
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..0bf46bfb4a6fac5a0304652e42566b2c991bab48 100644
|
||||
--- a/dist/providers/openai-responses.js
|
||||
+++ b/dist/providers/openai-responses.js
|
||||
@@ -396,10 +396,16 @@ function convertMessages(model, context) {
|
||||
}
|
||||
else if (msg.role === "assistant") {
|
||||
const output = [];
|
||||
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
|
||||
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
|
||||
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
|
||||
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
|
||||
for (const block of msg.content) {
|
||||
// Do not submit thinking blocks if the completion had an error (i.e. abort)
|
||||
if (block.type === "thinking" && msg.stopReason !== "error") {
|
||||
if (block.thinkingSignature) {
|
||||
+ if (!hasTextBlock)
|
||||
+ continue;
|
||||
const reasoningItem = JSON.parse(block.thinkingSignature);
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
Generated
+35
-44
@@ -8,24 +8,15 @@ overrides:
|
||||
'@sinclair/typebox': 0.34.47
|
||||
|
||||
patchedDependencies:
|
||||
'@buape/carbon@0.0.0-beta-20260109194934':
|
||||
hash: c36ae878bfd2e2ec2d6eb4fa12ebcd3b0797b033020c0a95d8a3e67ba82228e3
|
||||
path: patches/@buape__carbon@0.0.0-beta-20260109194934.patch
|
||||
'@mariozechner/pi-agent-core':
|
||||
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
|
||||
path: patches/@mariozechner__pi-agent-core.patch
|
||||
'@mariozechner/pi-ai@0.42.2':
|
||||
hash: 0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c
|
||||
path: patches/@mariozechner__pi-ai@0.42.2.patch
|
||||
'@mariozechner/pi-ai':
|
||||
hash: 3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c
|
||||
path: patches/@mariozechner__pi-ai.patch
|
||||
'@mariozechner/pi-coding-agent':
|
||||
hash: 58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e
|
||||
path: patches/@mariozechner__pi-coding-agent.patch
|
||||
playwright-core@1.57.0:
|
||||
hash: 66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02
|
||||
path: patches/playwright-core@1.57.0.patch
|
||||
qrcode-terminal:
|
||||
hash: ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12
|
||||
path: patches/qrcode-terminal.patch
|
||||
|
||||
importers:
|
||||
|
||||
@@ -33,7 +24,7 @@ importers:
|
||||
dependencies:
|
||||
'@buape/carbon':
|
||||
specifier: 0.0.0-beta-20260109194934
|
||||
version: 0.0.0-beta-20260109194934(patch_hash=c36ae878bfd2e2ec2d6eb4fa12ebcd3b0797b033020c0a95d8a3e67ba82228e3)(hono@4.11.3)
|
||||
version: 0.0.0-beta-20260109194934(hono@4.11.3)
|
||||
'@clack/prompts':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
@@ -47,17 +38,17 @@ importers:
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: ^0.42.2
|
||||
version: 0.42.2(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
specifier: ^0.42.1
|
||||
version: 0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.42.2
|
||||
version: 0.42.2(patch_hash=0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c)(ws@8.19.0)(zod@4.3.5)
|
||||
specifier: ^0.42.1
|
||||
version: 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.42.2
|
||||
version: 0.42.2(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)
|
||||
specifier: ^0.42.1
|
||||
version: 0.42.1(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.42.2
|
||||
version: 0.42.2
|
||||
specifier: ^0.42.1
|
||||
version: 0.42.1
|
||||
'@microsoft/agents-hosting':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -132,13 +123,13 @@ importers:
|
||||
version: 0.2.0
|
||||
playwright-core:
|
||||
specifier: 1.57.0
|
||||
version: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02)
|
||||
version: 1.57.0
|
||||
proper-lockfile:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
qrcode-terminal:
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12)
|
||||
version: 0.12.0
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -860,22 +851,22 @@ packages:
|
||||
peerDependencies:
|
||||
lit: ^3.3.1
|
||||
|
||||
'@mariozechner/pi-agent-core@0.42.2':
|
||||
resolution: {integrity: sha512-j81u9v6FhNgYXTTcgM5FB7f2hLQPc/73oTM0LuRDlybJakDRM3z/BBGkRk5csCfjYN1OeEGMjqj61pR30WqOZg==}
|
||||
'@mariozechner/pi-agent-core@0.42.1':
|
||||
resolution: {integrity: sha512-sIRB1jHheQSGONoVorrQ3X9vc1JFLoAe+48k7UJmpLAhJXnQmRtq1+CtzvKOVe5qVRyNBNuNdXipH+seicbwvQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-ai@0.42.2':
|
||||
resolution: {integrity: sha512-uIPfOCGSWm8Uo4kJ0nWKClJlIhBFxDasBbbAzzEgR9NrsyZMHuFYn4Y2XsbNKHmc4KnoS6DZvaO6IP+/+IS9rw==}
|
||||
'@mariozechner/pi-ai@0.42.1':
|
||||
resolution: {integrity: sha512-uRiH+s7EPDz9Q7hjQJ4Mm8lU4e4/C8pB4rnqGds73B5/0rqZb8DeSBukKU6uOwMNkXol/tYgnkEqXy7qEoHLJQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.42.2':
|
||||
resolution: {integrity: sha512-Rlxo2rWU4RjwBe4jr/CnqqknbDF3fnb3RzCe9u1mMm4nb2T9qLP3QGDo2c5DhWEpcRZ54hrR6HTennlEazfXvA==}
|
||||
'@mariozechner/pi-coding-agent@0.42.1':
|
||||
resolution: {integrity: sha512-BsLgFBC//98EdQCCoNYPfxKi2TEJ64a9w/CQC8/DyeAVhjrdJELUd+3EoRV6iiodbpUspUASW9w3ELoqQGAtoA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-tui@0.42.2':
|
||||
resolution: {integrity: sha512-im3HwwKvSlh+N1hJpKVoZYW1JrXz0N3eN/PfpWX8DJYYTTG4RDaT0Mbbf0vUbkKSqCfeJQZCN5bfCocTXlU8wA==}
|
||||
'@mariozechner/pi-tui@0.42.1':
|
||||
resolution: {integrity: sha512-dsmzcOjD+9MNgqnJ6/BJ255Sc9KQWN4Asyh1ron7bMA4Q9lqSWl6q8dk78DBp2QcVeWHtvqbwdZwNnDwWyxw3g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@microsoft/agents-activity@1.1.1':
|
||||
@@ -3383,7 +3374,7 @@ snapshots:
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
|
||||
'@buape/carbon@0.0.0-beta-20260109194934(patch_hash=c36ae878bfd2e2ec2d6eb4fa12ebcd3b0797b033020c0a95d8a3e67ba82228e3)(hono@4.11.3)':
|
||||
'@buape/carbon@0.0.0-beta-20260109194934(hono@4.11.3)':
|
||||
dependencies:
|
||||
'@types/node': 24.10.4
|
||||
discord-api-types: 0.38.36
|
||||
@@ -3790,10 +3781,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
|
||||
'@mariozechner/pi-agent-core@0.42.2(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
|
||||
'@mariozechner/pi-agent-core@0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.42.2(patch_hash=0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.42.2
|
||||
'@mariozechner/pi-ai': 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.42.1
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
@@ -3802,7 +3793,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.42.2(patch_hash=0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c)(ws@8.19.0)(zod@4.3.5)':
|
||||
'@mariozechner/pi-ai@0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
||||
'@google/genai': 1.34.0
|
||||
@@ -3822,12 +3813,12 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.42.2(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)':
|
||||
'@mariozechner/pi-coding-agent@0.42.1(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@mariozechner/clipboard': 0.3.0
|
||||
'@mariozechner/pi-agent-core': 0.42.2(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.42.2(patch_hash=0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.42.2
|
||||
'@mariozechner/pi-agent-core': 0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.42.1
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
diff: 8.0.2
|
||||
@@ -3846,7 +3837,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-tui@0.42.2':
|
||||
'@mariozechner/pi-tui@0.42.1':
|
||||
dependencies:
|
||||
'@types/mime-types': 2.1.4
|
||||
chalk: 5.6.2
|
||||
@@ -5689,11 +5680,11 @@ snapshots:
|
||||
dependencies:
|
||||
pngjs: 7.0.0
|
||||
|
||||
playwright-core@1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02): {}
|
||||
playwright-core@1.57.0: {}
|
||||
|
||||
playwright@1.57.0:
|
||||
dependencies:
|
||||
playwright-core: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02)
|
||||
playwright-core: 1.57.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
@@ -5778,7 +5769,7 @@ snapshots:
|
||||
'@thi.ng/bitstream': 2.4.37
|
||||
optional: true
|
||||
|
||||
qrcode-terminal@0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12): {}
|
||||
qrcode-terminal@0.12.0: {}
|
||||
|
||||
qs@6.14.1:
|
||||
dependencies:
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ onlyBuiltDependencies:
|
||||
- sharp
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai@0.42.1': patches/@mariozechner__pi-ai@0.42.1.patch
|
||||
'@mariozechner/pi-ai': patches/@mariozechner__pi-ai.patch
|
||||
@@ -5,12 +5,7 @@ import { execSync } from "node:child_process";
|
||||
type PackFile = { path: string };
|
||||
type PackResult = { files?: PackFile[] };
|
||||
|
||||
const requiredPaths = [
|
||||
"dist/discord/send.js",
|
||||
"dist/hooks/gmail.js",
|
||||
"dist/msteams/send.js",
|
||||
"dist/whatsapp/normalize.js",
|
||||
];
|
||||
const requiredPaths = ["dist/discord/send.js", "dist/hooks/gmail.js"];
|
||||
const forbiddenPrefixes = ["dist/Clawdbot.app/"];
|
||||
|
||||
function runPackDry(): PackResult[] {
|
||||
|
||||
@@ -25,15 +25,9 @@ else
|
||||
CHROME_ARGS=()
|
||||
fi
|
||||
|
||||
if [[ "${CDP_PORT}" -ge 65535 ]]; then
|
||||
CHROME_CDP_PORT="$((CDP_PORT - 1))"
|
||||
else
|
||||
CHROME_CDP_PORT="$((CDP_PORT + 1))"
|
||||
fi
|
||||
|
||||
CHROME_ARGS+=(
|
||||
"--remote-debugging-address=127.0.0.1"
|
||||
"--remote-debugging-port=${CHROME_CDP_PORT}"
|
||||
"--remote-debugging-address=0.0.0.0"
|
||||
"--remote-debugging-port=${CDP_PORT}"
|
||||
"--user-data-dir=${HOME}/.chrome"
|
||||
"--no-first-run"
|
||||
"--no-default-browser-check"
|
||||
@@ -48,17 +42,6 @@ CHROME_ARGS+=(
|
||||
|
||||
chromium "${CHROME_ARGS[@]}" about:blank &
|
||||
|
||||
for _ in $(seq 1 50); do
|
||||
if curl -sS --max-time 1 "http://127.0.0.1:${CHROME_CDP_PORT}/json/version" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
socat \
|
||||
TCP-LISTEN:"${CDP_PORT}",fork,reuseaddr,bind=0.0.0.0 \
|
||||
TCP:127.0.0.1:"${CHROME_CDP_PORT}" &
|
||||
|
||||
if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then
|
||||
x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -nopw -localhost &
|
||||
websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" &
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE_NAME="${CLAWDBOT_IMAGE:-clawdbot:local}"
|
||||
CONFIG_DIR="${CLAWDBOT_CONFIG_DIR:-$HOME/.clawdbot}"
|
||||
WORKSPACE_DIR="${CLAWDBOT_WORKSPACE_DIR:-$HOME/clawd}"
|
||||
PROFILE_FILE="${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}"
|
||||
|
||||
PROFILE_MOUNT=()
|
||||
if [[ -f "$PROFILE_FILE" ]]; then
|
||||
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/node/.profile:ro)
|
||||
fi
|
||||
|
||||
echo "==> Build image: $IMAGE_NAME"
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "==> Run gateway live model tests (profile keys)"
|
||||
docker run --rm -t \
|
||||
--entrypoint bash \
|
||||
-e HOME=/home/node \
|
||||
-e CLAWDBOT_LIVE_TEST=1 \
|
||||
-e CLAWDBOT_LIVE_GATEWAY=1 \
|
||||
-e CLAWDBOT_LIVE_GATEWAY_ALL_MODELS=1 \
|
||||
-e CLAWDBOT_LIVE_GATEWAY_MODELS="${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}" \
|
||||
-v "$CONFIG_DIR":/home/node/.clawdbot \
|
||||
-v "$WORKSPACE_DIR":/home/node/clawd \
|
||||
"${PROFILE_MOUNT[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
-lc "set -euo pipefail; [ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" || true; cd /app && pnpm test:live"
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE_NAME="${CLAWDBOT_IMAGE:-clawdbot:local}"
|
||||
CONFIG_DIR="${CLAWDBOT_CONFIG_DIR:-$HOME/.clawdbot}"
|
||||
WORKSPACE_DIR="${CLAWDBOT_WORKSPACE_DIR:-$HOME/clawd}"
|
||||
PROFILE_FILE="${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}"
|
||||
|
||||
PROFILE_MOUNT=()
|
||||
if [[ -f "$PROFILE_FILE" ]]; then
|
||||
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/node/.profile:ro)
|
||||
fi
|
||||
|
||||
echo "==> Build image: $IMAGE_NAME"
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "==> Run live model tests (profile keys)"
|
||||
docker run --rm -t \
|
||||
--entrypoint bash \
|
||||
-e HOME=/home/node \
|
||||
-e CLAWDBOT_LIVE_TEST=1 \
|
||||
-e CLAWDBOT_LIVE_ALL_MODELS=1 \
|
||||
-e CLAWDBOT_LIVE_MODELS="${CLAWDBOT_LIVE_MODELS:-all}" \
|
||||
-v "$CONFIG_DIR":/home/node/.clawdbot \
|
||||
-v "$WORKSPACE_DIR":/home/node/clawd \
|
||||
"${PROFILE_MOUNT[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
-lc "set -euo pipefail; [ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" || true; cd /app && pnpm test:live"
|
||||
@@ -3,11 +3,7 @@ import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
|
||||
|
||||
@@ -53,26 +49,6 @@ export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
|
||||
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
export function resolveSessionAgentIds(params: {
|
||||
sessionKey?: string;
|
||||
config?: ClawdbotConfig;
|
||||
}): { defaultAgentId: string; sessionAgentId: string } {
|
||||
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
|
||||
const sessionAgentId = parsed?.agentId
|
||||
? normalizeAgentId(parsed.agentId)
|
||||
: defaultAgentId;
|
||||
return { defaultAgentId, sessionAgentId };
|
||||
}
|
||||
|
||||
export function resolveSessionAgentId(params: {
|
||||
sessionKey?: string;
|
||||
config?: ClawdbotConfig;
|
||||
}): string {
|
||||
return resolveSessionAgentIds(params).sessionAgentId;
|
||||
}
|
||||
|
||||
function resolveAgentEntry(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
|
||||
@@ -74,7 +74,6 @@ export type AuthProfileCredential =
|
||||
|
||||
export type AuthProfileFailureReason =
|
||||
| "auth"
|
||||
| "format"
|
||||
| "rate_limit"
|
||||
| "billing"
|
||||
| "timeout"
|
||||
|
||||
@@ -44,7 +44,7 @@ const DEFAULT_PATH =
|
||||
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
|
||||
// Type.Union of literals compiles to { anyOf: [{enum:["a"]}, {enum:["b"]}, ...] }
|
||||
// which is valid but not accepted. A flat enum { type: "string", enum: [...] } works.
|
||||
const _stringEnum = <T extends readonly string[]>(
|
||||
const stringEnum = <T extends readonly string[]>(
|
||||
values: T,
|
||||
options?: { description?: string },
|
||||
) =>
|
||||
@@ -453,7 +453,12 @@ export function createBashTool(
|
||||
export const bashTool = createBashTool();
|
||||
|
||||
const processSchema = Type.Object({
|
||||
action: Type.String({ description: "Process action" }),
|
||||
action: stringEnum(
|
||||
["list", "poll", "log", "write", "kill", "clear", "remove"] as const,
|
||||
{
|
||||
description: "Process action",
|
||||
},
|
||||
),
|
||||
sessionId: Type.Optional(
|
||||
Type.String({ description: "Session id for actions other than list" }),
|
||||
),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { shouldLogVerbose } from "../globals.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
@@ -137,7 +136,6 @@ function buildSystemPrompt(params: {
|
||||
defaultThinkLevel?: ThinkLevel;
|
||||
extraSystemPrompt?: string;
|
||||
ownerNumbers?: string[];
|
||||
heartbeatPrompt?: string;
|
||||
tools: AgentTool[];
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
modelDisplay: string;
|
||||
@@ -152,7 +150,9 @@ function buildSystemPrompt(params: {
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint: false,
|
||||
heartbeatPrompt: params.heartbeatPrompt,
|
||||
heartbeatPrompt: resolveHeartbeatPrompt(
|
||||
params.config?.agents?.defaults?.heartbeat?.prompt,
|
||||
),
|
||||
runtimeInfo: {
|
||||
host: "clawdbot",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
@@ -374,23 +374,12 @@ export async function runClaudeCliAgent(params: {
|
||||
params.sessionKey ?? params.sessionId,
|
||||
);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const heartbeatPrompt =
|
||||
sessionAgentId === defaultAgentId
|
||||
? resolveHeartbeatPrompt(
|
||||
params.config?.agents?.defaults?.heartbeat?.prompt,
|
||||
)
|
||||
: undefined;
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
workspaceDir,
|
||||
config: params.config,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
heartbeatPrompt,
|
||||
tools: [],
|
||||
contextFiles,
|
||||
modelDisplay,
|
||||
|
||||
@@ -15,14 +15,6 @@ describe("failover-error", () => {
|
||||
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
|
||||
});
|
||||
|
||||
it("infers format errors from error messages", () => {
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
message: "invalid request format: messages.1.content.1.tool_use.id",
|
||||
}),
|
||||
).toBe("format");
|
||||
});
|
||||
|
||||
it("infers timeout from common node error codes", () => {
|
||||
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe(
|
||||
"timeout",
|
||||
@@ -44,15 +36,6 @@ describe("failover-error", () => {
|
||||
expect(err?.model).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("coerces format errors with a 400 status", () => {
|
||||
const err = coerceToFailoverError("invalid request format", {
|
||||
provider: "google",
|
||||
model: "cloud-code-assist",
|
||||
});
|
||||
expect(err?.reason).toBe("format");
|
||||
expect(err?.status).toBe(400);
|
||||
});
|
||||
|
||||
it("describes non-Error values consistently", () => {
|
||||
const described = describeFailoverError(123);
|
||||
expect(described.message).toBe("123");
|
||||
|
||||
@@ -50,8 +50,6 @@ export function resolveFailoverStatus(
|
||||
return 401;
|
||||
case "timeout":
|
||||
return 408;
|
||||
case "format":
|
||||
return 400;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import { getApiKeyForModel } from "./model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
|
||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const ALL_MODELS =
|
||||
process.env.CLAWDBOT_LIVE_ALL_MODELS === "1" ||
|
||||
process.env.CLAWDBOT_LIVE_MODELS === "all";
|
||||
const REQUIRE_PROFILE_KEYS =
|
||||
process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
|
||||
|
||||
const describeLive = LIVE && ALL_MODELS ? describe : describe.skip;
|
||||
|
||||
function parseModelFilter(raw?: string): Set<string> | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed || trimmed === "all") return null;
|
||||
const ids = trimmed
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return ids.length ? new Set(ids) : null;
|
||||
}
|
||||
|
||||
describeLive("live models (profile keys)", () => {
|
||||
it(
|
||||
"completes across configured models",
|
||||
async () => {
|
||||
const cfg = loadConfig();
|
||||
await ensureClawdbotModelsJson(cfg);
|
||||
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, agentDir);
|
||||
const models = modelRegistry.getAll() as Array<Model<Api>>;
|
||||
|
||||
const filter = parseModelFilter(process.env.CLAWDBOT_LIVE_MODELS);
|
||||
|
||||
const failures: Array<{ model: string; error: string }> = [];
|
||||
const skipped: Array<{ model: string; reason: string }> = [];
|
||||
|
||||
for (const model of models) {
|
||||
const id = `${model.provider}/${model.id}`;
|
||||
if (filter && !filter.has(id)) continue;
|
||||
|
||||
let apiKeyInfo: Awaited<ReturnType<typeof getApiKeyForModel>>;
|
||||
try {
|
||||
apiKeyInfo = await getApiKeyForModel({ model, cfg });
|
||||
} catch (err) {
|
||||
skipped.push({ model: id, reason: String(err) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (REQUIRE_PROFILE_KEYS && !apiKeyInfo.source.startsWith("profile:")) {
|
||||
skipped.push({
|
||||
model: id,
|
||||
reason: `non-profile credential source: ${apiKeyInfo.source}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Special regression: OpenAI rejects replayed `reasoning` items for tool-only turns.
|
||||
if (
|
||||
model.provider === "openai" &&
|
||||
model.api === "openai-responses" &&
|
||||
model.id === "gpt-5.2"
|
||||
) {
|
||||
const noopTool = {
|
||||
name: "noop",
|
||||
description: "Return ok.",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
};
|
||||
|
||||
const first = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Call the tool `noop` with {}. Do not write any other text.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [noopTool],
|
||||
},
|
||||
{
|
||||
apiKey: apiKeyInfo.apiKey,
|
||||
reasoning: model.reasoning ? "low" : undefined,
|
||||
maxTokens: 128,
|
||||
temperature: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const toolCall = first.content.find((b) => b.type === "toolCall");
|
||||
expect(toolCall).toBeTruthy();
|
||||
if (!toolCall || toolCall.type !== "toolCall") {
|
||||
throw new Error("expected tool call");
|
||||
}
|
||||
|
||||
const second = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Call the tool `noop` with {}. Do not write any other text.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
first,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with the word ok.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: apiKeyInfo.apiKey,
|
||||
reasoning: model.reasoning ? "low" : undefined,
|
||||
maxTokens: 64,
|
||||
temperature: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const secondText = second.content
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text.trim())
|
||||
.join(" ");
|
||||
expect(secondText.length).toBeGreaterThan(0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const res = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with the word ok.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: apiKeyInfo.apiKey,
|
||||
reasoning: model.reasoning ? "low" : undefined,
|
||||
maxTokens: 64,
|
||||
temperature: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const text = res.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.join(" ");
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
} catch (err) {
|
||||
failures.push({ model: id, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const preview = failures
|
||||
.slice(0, 10)
|
||||
.map((f) => `- ${f.model}: ${f.error}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`live model failures (${failures.length}):\n${preview}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep one assertion so the test fails loudly if we somehow ran nothing.
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
void skipped;
|
||||
},
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Model,
|
||||
ToolResultMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { streamOpenAIResponses } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function buildModel(): Model<"openai-responses"> {
|
||||
return {
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
};
|
||||
}
|
||||
|
||||
function installFailingFetchCapture() {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let lastBody: unknown;
|
||||
|
||||
const fetchImpl: typeof fetch = async (_input, init) => {
|
||||
const rawBody = init?.body;
|
||||
const bodyText = (() => {
|
||||
if (!rawBody) return "";
|
||||
if (typeof rawBody === "string") return rawBody;
|
||||
if (rawBody instanceof Uint8Array)
|
||||
return Buffer.from(rawBody).toString("utf8");
|
||||
if (rawBody instanceof ArrayBuffer)
|
||||
return Buffer.from(new Uint8Array(rawBody)).toString("utf8");
|
||||
return String(rawBody);
|
||||
})();
|
||||
lastBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined;
|
||||
throw new Error("intentional fetch abort (test)");
|
||||
};
|
||||
|
||||
globalThis.fetch = fetchImpl;
|
||||
|
||||
return {
|
||||
getLastBody: () => lastBody as Record<string, unknown> | undefined,
|
||||
restore: () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("openai-responses reasoning replay", () => {
|
||||
it("does not replay standalone reasoning for tool-call-only turns", async () => {
|
||||
const cap = installFailingFetchCapture();
|
||||
try {
|
||||
const model = buildModel();
|
||||
|
||||
const assistantToolOnly: AssistantMessage = {
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal",
|
||||
thinkingSignature: JSON.stringify({
|
||||
type: "reasoning",
|
||||
id: "rs_test",
|
||||
summary: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_123|fc_123",
|
||||
name: "noop",
|
||||
arguments: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const toolResult: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call noop.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
assistantToolOnly,
|
||||
toolResult,
|
||||
{
|
||||
role: "user",
|
||||
content: "Now reply with ok.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "noop",
|
||||
description: "no-op",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ apiKey: "test" },
|
||||
);
|
||||
|
||||
await stream.result();
|
||||
|
||||
const body = cap.getLastBody();
|
||||
const input = Array.isArray(body?.input) ? body?.input : [];
|
||||
const types = input
|
||||
.map((item) =>
|
||||
item && typeof item === "object"
|
||||
? (item as Record<string, unknown>).type
|
||||
: undefined,
|
||||
)
|
||||
.filter((t): t is string => typeof t === "string");
|
||||
|
||||
expect(types).toContain("function_call");
|
||||
expect(types).not.toContain("reasoning");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("still replays reasoning when paired with an assistant message", async () => {
|
||||
const cap = installFailingFetchCapture();
|
||||
try {
|
||||
const model = buildModel();
|
||||
|
||||
const assistantWithText: AssistantMessage = {
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal",
|
||||
thinkingSignature: JSON.stringify({
|
||||
type: "reasoning",
|
||||
id: "rs_test",
|
||||
summary: [],
|
||||
}),
|
||||
},
|
||||
{ type: "text", text: "hello", textSignature: "msg_test" },
|
||||
],
|
||||
};
|
||||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{ role: "user", content: "Hi", timestamp: Date.now() },
|
||||
assistantWithText,
|
||||
{ role: "user", content: "Ok", timestamp: Date.now() },
|
||||
],
|
||||
},
|
||||
{ apiKey: "test" },
|
||||
);
|
||||
|
||||
await stream.result();
|
||||
|
||||
const body = cap.getLastBody();
|
||||
const input = Array.isArray(body?.input) ? body?.input : [];
|
||||
const types = input
|
||||
.map((item) =>
|
||||
item && typeof item === "object"
|
||||
? (item as Record<string, unknown>).type
|
||||
: undefined,
|
||||
)
|
||||
.filter((t): t is string => typeof t === "string");
|
||||
|
||||
expect(types).toContain("reasoning");
|
||||
expect(types).toContain("message");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,6 @@ describe("isFailoverErrorMessage", () => {
|
||||
"429 rate limit exceeded",
|
||||
"Your credit balance is too low",
|
||||
"request timed out",
|
||||
"invalid request format",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
expect(isFailoverErrorMessage(sample)).toBe(true);
|
||||
@@ -264,12 +263,9 @@ describe("classifyFailoverReason", () => {
|
||||
expect(classifyFailoverReason("resource has been exhausted")).toBe(
|
||||
"rate_limit",
|
||||
);
|
||||
expect(classifyFailoverReason("invalid request format")).toBe("format");
|
||||
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
|
||||
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
||||
expect(classifyFailoverReason("string should match pattern")).toBe(
|
||||
"format",
|
||||
);
|
||||
expect(classifyFailoverReason("string should match pattern")).toBeNull();
|
||||
expect(classifyFailoverReason("bad request")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,84 +276,47 @@ export function isRateLimitAssistantError(
|
||||
msg: AssistantMessage | undefined,
|
||||
): boolean {
|
||||
if (!msg || msg.stopReason !== "error") return false;
|
||||
return isRateLimitErrorMessage(msg.errorMessage ?? "");
|
||||
}
|
||||
|
||||
type ErrorPattern = RegExp | string;
|
||||
|
||||
const ERROR_PATTERNS = {
|
||||
rateLimit: [
|
||||
/rate[_ ]limit|too many requests|429/,
|
||||
"exceeded your current quota",
|
||||
"resource has been exhausted",
|
||||
"quota exceeded",
|
||||
"resource_exhausted",
|
||||
],
|
||||
timeout: [
|
||||
"timeout",
|
||||
"timed out",
|
||||
"deadline exceeded",
|
||||
"context deadline exceeded",
|
||||
],
|
||||
billing: [
|
||||
/\b402\b/,
|
||||
"payment required",
|
||||
"insufficient credits",
|
||||
"credit balance",
|
||||
"plans & billing",
|
||||
],
|
||||
auth: [
|
||||
/invalid[_ ]?api[_ ]?key/,
|
||||
"incorrect api key",
|
||||
"invalid token",
|
||||
"authentication",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"access denied",
|
||||
"expired",
|
||||
"token has expired",
|
||||
/\b401\b/,
|
||||
/\b403\b/,
|
||||
],
|
||||
format: [
|
||||
"invalid_request_error",
|
||||
"string should match pattern",
|
||||
"tool_use.id",
|
||||
"tool_use_id",
|
||||
"messages.1.content.1.tool_use.id",
|
||||
"invalid request format",
|
||||
],
|
||||
} as const;
|
||||
|
||||
function matchesErrorPatterns(
|
||||
raw: string,
|
||||
patterns: readonly ErrorPattern[],
|
||||
): boolean {
|
||||
const raw = (msg.errorMessage ?? "").toLowerCase();
|
||||
if (!raw) return false;
|
||||
const value = raw.toLowerCase();
|
||||
return patterns.some((pattern) =>
|
||||
pattern instanceof RegExp ? pattern.test(value) : value.includes(pattern),
|
||||
);
|
||||
return isRateLimitErrorMessage(raw);
|
||||
}
|
||||
|
||||
export function isRateLimitErrorMessage(raw: string): boolean {
|
||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.rateLimit);
|
||||
const value = raw.toLowerCase();
|
||||
return (
|
||||
/rate[_ ]limit|too many requests|429/.test(value) ||
|
||||
value.includes("exceeded your current quota") ||
|
||||
value.includes("resource has been exhausted") ||
|
||||
value.includes("quota exceeded") ||
|
||||
value.includes("resource_exhausted")
|
||||
);
|
||||
}
|
||||
|
||||
export function isTimeoutErrorMessage(raw: string): boolean {
|
||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.timeout);
|
||||
const value = raw.toLowerCase();
|
||||
if (!value) return false;
|
||||
return (
|
||||
value.includes("timeout") ||
|
||||
value.includes("timed out") ||
|
||||
value.includes("deadline exceeded") ||
|
||||
value.includes("context deadline exceeded")
|
||||
);
|
||||
}
|
||||
|
||||
export function isBillingErrorMessage(raw: string): boolean {
|
||||
const value = raw.toLowerCase();
|
||||
if (!value) return false;
|
||||
if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) return true;
|
||||
return (
|
||||
value.includes("billing") &&
|
||||
(value.includes("upgrade") ||
|
||||
value.includes("credits") ||
|
||||
value.includes("payment") ||
|
||||
value.includes("plan"))
|
||||
/\b402\b/.test(value) ||
|
||||
value.includes("payment required") ||
|
||||
value.includes("insufficient credits") ||
|
||||
value.includes("credit balance") ||
|
||||
value.includes("plans & billing") ||
|
||||
(value.includes("billing") &&
|
||||
(value.includes("upgrade") ||
|
||||
value.includes("credits") ||
|
||||
value.includes("payment") ||
|
||||
value.includes("plan")))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,11 +328,34 @@ export function isBillingAssistantError(
|
||||
}
|
||||
|
||||
export function isAuthErrorMessage(raw: string): boolean {
|
||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.auth);
|
||||
const value = raw.toLowerCase();
|
||||
if (!value) return false;
|
||||
return (
|
||||
/invalid[_ ]?api[_ ]?key/.test(value) ||
|
||||
value.includes("incorrect api key") ||
|
||||
value.includes("invalid token") ||
|
||||
value.includes("authentication") ||
|
||||
value.includes("unauthorized") ||
|
||||
value.includes("forbidden") ||
|
||||
value.includes("access denied") ||
|
||||
value.includes("expired") ||
|
||||
value.includes("token has expired") ||
|
||||
/\b401\b/.test(value) ||
|
||||
/\b403\b/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isCloudCodeAssistFormatError(raw: string): boolean {
|
||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
|
||||
const value = raw.toLowerCase();
|
||||
if (!value) return false;
|
||||
return (
|
||||
value.includes("invalid_request_error") ||
|
||||
value.includes("string should match pattern") ||
|
||||
value.includes("tool_use.id") ||
|
||||
value.includes("tool_use_id") ||
|
||||
value.includes("messages.1.content.1.tool_use.id") ||
|
||||
value.includes("invalid request format")
|
||||
);
|
||||
}
|
||||
|
||||
export function isAuthAssistantError(
|
||||
@@ -381,18 +367,16 @@ export function isAuthAssistantError(
|
||||
|
||||
export type FailoverReason =
|
||||
| "auth"
|
||||
| "format"
|
||||
| "rate_limit"
|
||||
| "billing"
|
||||
| "timeout"
|
||||
| "unknown";
|
||||
|
||||
export function classifyFailoverReason(raw: string): FailoverReason | null {
|
||||
if (isAuthErrorMessage(raw)) return "auth";
|
||||
if (isRateLimitErrorMessage(raw)) return "rate_limit";
|
||||
if (isCloudCodeAssistFormatError(raw)) return "format";
|
||||
if (isBillingErrorMessage(raw)) return "billing";
|
||||
if (isTimeoutErrorMessage(raw)) return "timeout";
|
||||
if (isAuthErrorMessage(raw)) return "auth";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import {
|
||||
applyGoogleTurnOrderingFix,
|
||||
buildEmbeddedSandboxInfo,
|
||||
@@ -59,54 +57,6 @@ describe("buildEmbeddedSandboxInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionAgentIds", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "beta", default: true }],
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
it("falls back to the configured default when sessionKey is missing", () => {
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
config: cfg,
|
||||
});
|
||||
expect(defaultAgentId).toBe("beta");
|
||||
expect(sessionAgentId).toBe("beta");
|
||||
});
|
||||
|
||||
it("falls back to the configured default when sessionKey is non-agent", () => {
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: "telegram:slash:123",
|
||||
config: cfg,
|
||||
});
|
||||
expect(sessionAgentId).toBe("beta");
|
||||
});
|
||||
|
||||
it("falls back to the configured default for global sessions", () => {
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: "global",
|
||||
config: cfg,
|
||||
});
|
||||
expect(sessionAgentId).toBe("beta");
|
||||
});
|
||||
|
||||
it("keeps the agent id for provider-qualified agent sessions", () => {
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: "agent:beta:slack:channel:C1",
|
||||
config: cfg,
|
||||
});
|
||||
expect(sessionAgentId).toBe("beta");
|
||||
});
|
||||
|
||||
it("uses the agent id from agent session keys", () => {
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: "agent:main:main",
|
||||
config: cfg,
|
||||
});
|
||||
expect(sessionAgentId).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
function createStubTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
@@ -118,11 +68,12 @@ function createStubTool(name: string): AgentTool {
|
||||
}
|
||||
|
||||
describe("splitSdkTools", () => {
|
||||
// Tool names are now capitalized (Bash, Read, etc.) to bypass Anthropic OAuth blocking
|
||||
const tools = [
|
||||
createStubTool("read"),
|
||||
createStubTool("bash"),
|
||||
createStubTool("edit"),
|
||||
createStubTool("write"),
|
||||
createStubTool("Read"),
|
||||
createStubTool("Bash"),
|
||||
createStubTool("Edit"),
|
||||
createStubTool("Write"),
|
||||
createStubTool("browser"),
|
||||
];
|
||||
|
||||
@@ -133,25 +84,27 @@ describe("splitSdkTools", () => {
|
||||
});
|
||||
expect(builtInTools).toEqual([]);
|
||||
expect(customTools.map((tool) => tool.name)).toEqual([
|
||||
"read",
|
||||
"bash",
|
||||
"edit",
|
||||
"write",
|
||||
"Read",
|
||||
"Bash",
|
||||
"Edit",
|
||||
"Write",
|
||||
"browser",
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes all tools to customTools even when not sandboxed", () => {
|
||||
it("routes all tools to customTools even when not sandboxed (for OAuth compatibility)", () => {
|
||||
// All tools are now passed as customTools to bypass pi-coding-agent's
|
||||
// built-in tool filtering, which expects lowercase names.
|
||||
const { builtInTools, customTools } = splitSdkTools({
|
||||
tools,
|
||||
sandboxEnabled: false,
|
||||
});
|
||||
expect(builtInTools).toEqual([]);
|
||||
expect(customTools.map((tool) => tool.name)).toEqual([
|
||||
"read",
|
||||
"bash",
|
||||
"edit",
|
||||
"write",
|
||||
"Read",
|
||||
"Bash",
|
||||
"Edit",
|
||||
"Write",
|
||||
"browser",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
SettingsManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
||||
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
||||
import type {
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
@@ -29,6 +28,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveProviderCapabilities } from "../config/provider-capabilities.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import {
|
||||
type enqueueCommand,
|
||||
enqueueCommandInLane,
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
import { normalizeMessageProvider } from "../utils/message-provider.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import {
|
||||
markAuthProfileFailure,
|
||||
markAuthProfileGood,
|
||||
@@ -59,7 +58,6 @@ import {
|
||||
ensureAuthProfileStore,
|
||||
getApiKeyForModel,
|
||||
resolveAuthProfileOrder,
|
||||
resolveModelAuthMode,
|
||||
} from "./model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
import {
|
||||
@@ -606,8 +604,10 @@ export function createSystemPromptOverride(
|
||||
return () => trimmed;
|
||||
}
|
||||
|
||||
// We always pass tools via `customTools` so our policy filtering, sandbox integration,
|
||||
// and extended toolset remain consistent across providers.
|
||||
// Tool names are now capitalized (Bash, Read, Write, Edit) to bypass Anthropic's
|
||||
// OAuth token blocking of lowercase names. However, pi-coding-agent's SDK has
|
||||
// hardcoded lowercase names in its built-in tool registry, so we must pass ALL
|
||||
// tools as customTools to bypass the SDK's filtering.
|
||||
|
||||
type AnyAgentTool = AgentTool;
|
||||
|
||||
@@ -618,8 +618,9 @@ export function splitSdkTools(options: {
|
||||
builtInTools: AnyAgentTool[];
|
||||
customTools: ReturnType<typeof toToolDefinitions>;
|
||||
} {
|
||||
// Always pass all tools as customTools so the SDK doesn't "helpfully" swap in
|
||||
// its own built-in implementations (we need our tool wrappers + policy).
|
||||
// Always pass all tools as customTools to bypass pi-coding-agent's built-in
|
||||
// tool filtering, which expects lowercase names (bash, read, write, edit).
|
||||
// Our tools are now capitalized (Bash, Read, Write, Edit) for OAuth compatibility.
|
||||
const { tools } = options;
|
||||
return {
|
||||
builtInTools: [],
|
||||
@@ -841,7 +842,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
params.sessionKey ?? params.sessionId,
|
||||
);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const runAbortController = new AbortController();
|
||||
const tools = createClawdbotCodingTools({
|
||||
bash: {
|
||||
...params.config?.tools?.bash,
|
||||
@@ -853,9 +853,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
abortSignal: runAbortController.signal,
|
||||
modelProvider: model.provider,
|
||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||
// No currentChannelId/currentThreadTs for compaction - not in message context
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
@@ -884,23 +881,15 @@ export async function compactEmbeddedPiSession(params: {
|
||||
params.config?.agents?.defaults?.userTimezone,
|
||||
);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
// Only include heartbeat prompt for the default agent
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
heartbeatPrompt: isDefaultAgent
|
||||
? resolveHeartbeatPrompt(
|
||||
params.config?.agents?.defaults?.heartbeat?.prompt,
|
||||
)
|
||||
: undefined,
|
||||
heartbeatPrompt: resolveHeartbeatPrompt(
|
||||
params.config?.agents?.defaults?.heartbeat?.prompt,
|
||||
),
|
||||
skillsPrompt,
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
@@ -1028,7 +1017,6 @@ export async function runEmbeddedPiAgent(params: {
|
||||
onBlockReply?: (payload: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
audioAsVoice?: boolean;
|
||||
}) => void | Promise<void>;
|
||||
blockReplyBreak?: "text_end" | "message_end";
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
@@ -1057,7 +1045,6 @@ export async function runEmbeddedPiAgent(params: {
|
||||
const enqueueGlobal =
|
||||
params.enqueue ??
|
||||
((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
||||
const runAbortController = new AbortController();
|
||||
return enqueueCommandInLane(sessionLane, () =>
|
||||
enqueueGlobal(async () => {
|
||||
const started = Date.now();
|
||||
@@ -1236,9 +1223,6 @@ export async function runEmbeddedPiAgent(params: {
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
abortSignal: runAbortController.signal,
|
||||
modelProvider: model.provider,
|
||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
@@ -1258,23 +1242,15 @@ export async function runEmbeddedPiAgent(params: {
|
||||
params.config?.agents?.defaults?.userTimezone,
|
||||
);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
// Only include heartbeat prompt for the default agent
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
defaultThinkLevel: thinkLevel,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
heartbeatPrompt: isDefaultAgent
|
||||
? resolveHeartbeatPrompt(
|
||||
params.config?.agents?.defaults?.heartbeat?.prompt,
|
||||
)
|
||||
: undefined,
|
||||
heartbeatPrompt: resolveHeartbeatPrompt(
|
||||
params.config?.agents?.defaults?.heartbeat?.prompt,
|
||||
),
|
||||
skillsPrompt,
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
@@ -1350,7 +1326,6 @@ export async function runEmbeddedPiAgent(params: {
|
||||
const abortRun = (isTimeout = false) => {
|
||||
aborted = true;
|
||||
if (isTimeout) timedOut = true;
|
||||
runAbortController.abort();
|
||||
void session.abort();
|
||||
};
|
||||
let subscription: ReturnType<typeof subscribeEmbeddedPiSession>;
|
||||
@@ -1558,7 +1533,9 @@ export async function runEmbeddedPiAgent(params: {
|
||||
: false;
|
||||
|
||||
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
|
||||
const shouldRotate = (!aborted && failoverFailure) || timedOut;
|
||||
const shouldRotate =
|
||||
(!aborted && (failoverFailure || cloudCodeAssistFormatError)) ||
|
||||
timedOut;
|
||||
|
||||
if (shouldRotate) {
|
||||
// Mark current profile for cooldown before rotating
|
||||
@@ -1627,10 +1604,6 @@ export async function runEmbeddedPiAgent(params: {
|
||||
text: string;
|
||||
media?: string[];
|
||||
isError?: boolean;
|
||||
audioAsVoice?: boolean;
|
||||
replyToId?: string;
|
||||
replyToTag?: boolean;
|
||||
replyToCurrent?: boolean;
|
||||
}> = [];
|
||||
|
||||
const errorText = lastAssistant
|
||||
@@ -1647,23 +1620,10 @@ export async function runEmbeddedPiAgent(params: {
|
||||
if (inlineToolResults) {
|
||||
for (const { toolName, meta } of toolMetas) {
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
|
||||
const {
|
||||
text: cleanedText,
|
||||
mediaUrls,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
} = parseReplyDirectives(agg);
|
||||
const { text: cleanedText, mediaUrls } =
|
||||
splitMediaFromOutput(agg);
|
||||
if (cleanedText)
|
||||
replyItems.push({
|
||||
text: cleanedText,
|
||||
media: mediaUrls,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
});
|
||||
replyItems.push({ text: cleanedText, media: mediaUrls });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1682,46 +1642,18 @@ export async function runEmbeddedPiAgent(params: {
|
||||
? [fallbackAnswerText]
|
||||
: [];
|
||||
for (const text of answerTexts) {
|
||||
const {
|
||||
text: cleanedText,
|
||||
mediaUrls,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
} = parseReplyDirectives(text);
|
||||
if (
|
||||
!cleanedText &&
|
||||
(!mediaUrls || mediaUrls.length === 0) &&
|
||||
!audioAsVoice
|
||||
)
|
||||
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0))
|
||||
continue;
|
||||
replyItems.push({
|
||||
text: cleanedText,
|
||||
media: mediaUrls,
|
||||
audioAsVoice,
|
||||
replyToId,
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
});
|
||||
replyItems.push({ text: cleanedText, media: mediaUrls });
|
||||
}
|
||||
|
||||
// Check if any replyItem has audioAsVoice tag - if so, apply to all media payloads
|
||||
const hasAudioAsVoiceTag = replyItems.some(
|
||||
(item) => item.audioAsVoice,
|
||||
);
|
||||
const payloads = replyItems
|
||||
.map((item) => ({
|
||||
text: item.text?.trim() ? item.text.trim() : undefined,
|
||||
mediaUrls: item.media?.length ? item.media : undefined,
|
||||
mediaUrl: item.media?.[0],
|
||||
isError: item.isError,
|
||||
replyToId: item.replyToId,
|
||||
replyToTag: item.replyToTag,
|
||||
replyToCurrent: item.replyToCurrent,
|
||||
// Apply audioAsVoice to media payloads if tag was found anywhere in response
|
||||
audioAsVoice:
|
||||
item.audioAsVoice || (hasAudioAsVoiceTag && item.media?.length),
|
||||
}))
|
||||
.filter(
|
||||
(p) =>
|
||||
|
||||
@@ -3,12 +3,12 @@ import path from "node:path";
|
||||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
||||
import type { ReasoningLevel } from "../auto-reply/thinking.js";
|
||||
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
||||
@@ -262,7 +262,6 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
onBlockReply?: (payload: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
audioAsVoice?: boolean;
|
||||
}) => void | Promise<void>;
|
||||
blockReplyBreak?: "text_end" | "message_end";
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
@@ -383,7 +382,7 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
const emitToolSummary = (toolName?: string, meta?: string) => {
|
||||
if (!params.onToolResult) return;
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
|
||||
const { text: cleanedText, mediaUrls } = parseReplyDirectives(agg);
|
||||
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
|
||||
try {
|
||||
void params.onToolResult({
|
||||
@@ -437,15 +436,11 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
lastBlockReplyText = chunk;
|
||||
assistantTexts.push(chunk);
|
||||
if (!params.onBlockReply) return;
|
||||
const splitResult = parseReplyDirectives(chunk);
|
||||
const { text: cleanedText, mediaUrls, audioAsVoice } = splitResult;
|
||||
// Skip empty payloads, but always emit if audioAsVoice is set (to propagate the flag)
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice)
|
||||
return;
|
||||
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(chunk);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
|
||||
void params.onBlockReply({
|
||||
text: cleanedText,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
audioAsVoice,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -739,7 +734,7 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
if (next && next !== lastStreamedAssistant) {
|
||||
lastStreamedAssistant = next;
|
||||
const { text: cleanedText, mediaUrls } =
|
||||
parseReplyDirectives(next);
|
||||
splitMediaFromOutput(next);
|
||||
emitAgentEvent({
|
||||
runId: params.runId,
|
||||
stream: "assistant",
|
||||
@@ -864,21 +859,12 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
);
|
||||
} else {
|
||||
lastBlockReplyText = text;
|
||||
const {
|
||||
text: cleanedText,
|
||||
mediaUrls,
|
||||
audioAsVoice,
|
||||
} = parseReplyDirectives(text);
|
||||
// Emit if there's content OR audioAsVoice flag (to propagate the flag)
|
||||
if (
|
||||
cleanedText ||
|
||||
(mediaUrls && mediaUrls.length > 0) ||
|
||||
audioAsVoice
|
||||
) {
|
||||
const { text: cleanedText, mediaUrls } =
|
||||
splitMediaFromOutput(text);
|
||||
if (cleanedText || (mediaUrls && mediaUrls.length > 0)) {
|
||||
void onBlockReply({
|
||||
text: cleanedText,
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
audioAsVoice,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ describe("Agent-specific tool filtering", () => {
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain("read");
|
||||
expect(toolNames).toContain("write");
|
||||
expect(toolNames).not.toContain("bash");
|
||||
expect(toolNames).toContain("Read");
|
||||
expect(toolNames).toContain("Write");
|
||||
expect(toolNames).not.toContain("Bash");
|
||||
});
|
||||
|
||||
it("should keep global tool policy when agent only sets tools.elevated", () => {
|
||||
@@ -62,9 +62,9 @@ describe("Agent-specific tool filtering", () => {
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain("bash");
|
||||
expect(toolNames).toContain("read");
|
||||
expect(toolNames).not.toContain("write");
|
||||
expect(toolNames).toContain("Bash");
|
||||
expect(toolNames).toContain("Read");
|
||||
expect(toolNames).not.toContain("Write");
|
||||
});
|
||||
|
||||
it("should apply agent-specific tool policy", () => {
|
||||
@@ -95,10 +95,10 @@ describe("Agent-specific tool filtering", () => {
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain("read");
|
||||
expect(toolNames).not.toContain("bash");
|
||||
expect(toolNames).not.toContain("write");
|
||||
expect(toolNames).not.toContain("edit");
|
||||
expect(toolNames).toContain("Read");
|
||||
expect(toolNames).not.toContain("Bash");
|
||||
expect(toolNames).not.toContain("Write");
|
||||
expect(toolNames).not.toContain("Edit");
|
||||
});
|
||||
|
||||
it("should allow different tool policies for different agents", () => {
|
||||
@@ -130,9 +130,9 @@ describe("Agent-specific tool filtering", () => {
|
||||
agentDir: "/tmp/agent-main",
|
||||
});
|
||||
const mainToolNames = mainTools.map((t) => t.name);
|
||||
expect(mainToolNames).toContain("bash");
|
||||
expect(mainToolNames).toContain("write");
|
||||
expect(mainToolNames).toContain("edit");
|
||||
expect(mainToolNames).toContain("Bash");
|
||||
expect(mainToolNames).toContain("Write");
|
||||
expect(mainToolNames).toContain("Edit");
|
||||
|
||||
// family agent: restricted
|
||||
const familyTools = createClawdbotCodingTools({
|
||||
@@ -142,10 +142,10 @@ describe("Agent-specific tool filtering", () => {
|
||||
agentDir: "/tmp/agent-family",
|
||||
});
|
||||
const familyToolNames = familyTools.map((t) => t.name);
|
||||
expect(familyToolNames).toContain("read");
|
||||
expect(familyToolNames).not.toContain("bash");
|
||||
expect(familyToolNames).not.toContain("write");
|
||||
expect(familyToolNames).not.toContain("edit");
|
||||
expect(familyToolNames).toContain("Read");
|
||||
expect(familyToolNames).not.toContain("Bash");
|
||||
expect(familyToolNames).not.toContain("Write");
|
||||
expect(familyToolNames).not.toContain("Edit");
|
||||
});
|
||||
|
||||
it("should prefer agent-specific tool policy over global", () => {
|
||||
@@ -176,7 +176,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
// Agent policy overrides global: browser is allowed again
|
||||
expect(toolNames).toContain("browser");
|
||||
expect(toolNames).not.toContain("bash");
|
||||
expect(toolNames).not.toContain("Bash");
|
||||
expect(toolNames).not.toContain("process");
|
||||
});
|
||||
|
||||
@@ -247,9 +247,9 @@ describe("Agent-specific tool filtering", () => {
|
||||
// Agent policy should be applied first, then sandbox
|
||||
// Agent allows only "read", sandbox allows ["read", "write", "bash"]
|
||||
// Result: only "read" (most restrictive wins)
|
||||
expect(toolNames).toContain("read");
|
||||
expect(toolNames).not.toContain("bash");
|
||||
expect(toolNames).not.toContain("write");
|
||||
expect(toolNames).toContain("Read");
|
||||
expect(toolNames).not.toContain("Bash");
|
||||
expect(toolNames).not.toContain("Write");
|
||||
});
|
||||
|
||||
it("should run bash synchronously when process is denied", async () => {
|
||||
@@ -265,7 +265,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
workspaceDir: "/tmp/test-main",
|
||||
agentDir: "/tmp/agent-main",
|
||||
});
|
||||
const bash = tools.find((tool) => tool.name === "bash");
|
||||
const bash = tools.find((tool) => tool.name === "Bash");
|
||||
expect(bash).toBeDefined();
|
||||
|
||||
const result = await bash?.execute("call1", {
|
||||
|
||||
+44
-31
@@ -31,19 +31,35 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
});
|
||||
|
||||
it("exposes raw for gateway config.apply tool calls", () => {
|
||||
it("requires 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>;
|
||||
allOf?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(parameters.type).toBe("object");
|
||||
expect(parameters.properties?.raw).toBeDefined();
|
||||
expect(parameters.required ?? []).not.toContain("raw");
|
||||
const conditional = parameters.allOf?.find(
|
||||
(entry) => "if" in entry && "then" in entry,
|
||||
) as
|
||||
| { if?: Record<string, unknown>; then?: Record<string, unknown> }
|
||||
| undefined;
|
||||
|
||||
expect(conditional).toBeDefined();
|
||||
const thenRequired = conditional?.then?.required as string[] | undefined;
|
||||
expect(thenRequired ?? []).toContain("raw");
|
||||
|
||||
const action = (
|
||||
conditional?.if?.properties as Record<string, unknown> | undefined
|
||||
)?.action as { const?: unknown; enum?: unknown[] } | undefined;
|
||||
const values = new Set<string>();
|
||||
if (typeof action?.const === "string") values.add(action.const);
|
||||
if (Array.isArray(action?.enum)) {
|
||||
for (const value of action.enum) {
|
||||
if (typeof value === "string") values.add(value);
|
||||
}
|
||||
}
|
||||
expect(values.has("config.apply")).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
|
||||
@@ -154,22 +170,11 @@ describe("createClawdbotCodingTools", () => {
|
||||
|
||||
it("includes bash and process tools", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
|
||||
// NOTE: bash/read/write/edit are capitalized to bypass Anthropic OAuth blocking
|
||||
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "process")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps canonical tool names for Anthropic OAuth (pi-ai remaps on the wire)", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
modelProvider: "anthropic",
|
||||
modelAuthMode: "oauth",
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("bash")).toBe(true);
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("write")).toBe(true);
|
||||
expect(names.has("edit")).toBe(true);
|
||||
});
|
||||
|
||||
it("provides top-level object schemas for all tools", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const offenders = tools
|
||||
@@ -208,8 +213,9 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(names.has("sessions_send")).toBe(false);
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("bash")).toBe(true);
|
||||
// NOTE: bash/read/write/edit are capitalized to bypass Anthropic OAuth blocking
|
||||
expect(names.has("Read")).toBe(true);
|
||||
expect(names.has("Bash")).toBe(true);
|
||||
expect(names.has("process")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -228,12 +234,14 @@ describe("createClawdbotCodingTools", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
||||
// Tool names are capitalized for OAuth compatibility
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["Read"]);
|
||||
});
|
||||
|
||||
it("keeps read tool image metadata intact", async () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const readTool = tools.find((tool) => tool.name === "read");
|
||||
// NOTE: read is capitalized to bypass Anthropic OAuth blocking
|
||||
const readTool = tools.find((tool) => tool.name === "Read");
|
||||
expect(readTool).toBeDefined();
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
||||
@@ -273,7 +281,8 @@ describe("createClawdbotCodingTools", () => {
|
||||
|
||||
it("returns text content without image blocks for text files", async () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const readTool = tools.find((tool) => tool.name === "read");
|
||||
// NOTE: read is capitalized to bypass Anthropic OAuth blocking
|
||||
const readTool = tools.find((tool) => tool.name === "Read");
|
||||
expect(readTool).toBeDefined();
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
||||
@@ -328,8 +337,10 @@ describe("createClawdbotCodingTools", () => {
|
||||
},
|
||||
};
|
||||
const tools = createClawdbotCodingTools({ sandbox });
|
||||
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "read")).toBe(false);
|
||||
// NOTE: bash/read are capitalized to bypass Anthropic OAuth blocking
|
||||
// Policy matching is case-insensitive, so allow: ["bash"] matches tool named "Bash"
|
||||
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "Read")).toBe(false);
|
||||
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -359,16 +370,18 @@ describe("createClawdbotCodingTools", () => {
|
||||
},
|
||||
};
|
||||
const tools = createClawdbotCodingTools({ sandbox });
|
||||
expect(tools.some((tool) => tool.name === "read")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "write")).toBe(false);
|
||||
expect(tools.some((tool) => tool.name === "edit")).toBe(false);
|
||||
// NOTE: read/write/edit are capitalized to bypass Anthropic OAuth blocking
|
||||
expect(tools.some((tool) => tool.name === "Read")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "Write")).toBe(false);
|
||||
expect(tools.some((tool) => tool.name === "Edit")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters tools by agent tool policy even without sandbox", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { deny: ["browser"] } },
|
||||
});
|
||||
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
|
||||
// NOTE: bash is capitalized to bypass Anthropic OAuth blocking
|
||||
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
+25
-63
@@ -23,7 +23,6 @@ import {
|
||||
type ProcessToolDefaults,
|
||||
} from "./bash-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import type { ModelAuthMode } from "./model-auth.js";
|
||||
import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
|
||||
@@ -284,6 +283,28 @@ function normalizeToolNames(list?: string[]) {
|
||||
return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
|
||||
* Renaming to capitalized versions bypasses the block while maintaining compatibility
|
||||
* with regular API keys.
|
||||
*/
|
||||
const OAUTH_BLOCKED_TOOL_NAMES: Record<string, string> = {
|
||||
bash: "Bash",
|
||||
read: "Read",
|
||||
write: "Write",
|
||||
edit: "Edit",
|
||||
};
|
||||
|
||||
function renameBlockedToolsForOAuth(tools: AnyAgentTool[]): AnyAgentTool[] {
|
||||
return tools.map((tool) => {
|
||||
const newName = OAUTH_BLOCKED_TOOL_NAMES[tool.name];
|
||||
if (newName) {
|
||||
return { ...tool, name: newName };
|
||||
}
|
||||
return tool;
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_SUBAGENT_TOOL_DENY = [
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
@@ -482,48 +503,6 @@ export const __testing = {
|
||||
cleanToolSchemaForGemini,
|
||||
} as const;
|
||||
|
||||
function throwAbortError(): never {
|
||||
const err = new Error("Aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
|
||||
function combineAbortSignals(
|
||||
a?: AbortSignal,
|
||||
b?: AbortSignal,
|
||||
): AbortSignal | undefined {
|
||||
if (!a && !b) return undefined;
|
||||
if (a && !b) return a;
|
||||
if (b && !a) return b;
|
||||
if (a?.aborted) return a;
|
||||
if (b?.aborted) return b;
|
||||
if (typeof AbortSignal.any === "function") {
|
||||
return AbortSignal.any([a as AbortSignal, b as AbortSignal]);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
a?.addEventListener("abort", onAbort, { once: true });
|
||||
b?.addEventListener("abort", onAbort, { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function wrapToolWithAbortSignal(
|
||||
tool: AnyAgentTool,
|
||||
abortSignal?: AbortSignal,
|
||||
): AnyAgentTool {
|
||||
if (!abortSignal) return tool;
|
||||
const execute = tool.execute;
|
||||
if (!execute) return tool;
|
||||
return {
|
||||
...tool,
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
const combined = combineAbortSignals(signal, abortSignal);
|
||||
if (combined?.aborted) throwAbortError();
|
||||
return await execute(toolCallId, params, combined, onUpdate);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createClawdbotCodingTools(options?: {
|
||||
bash?: BashToolDefaults & ProcessToolDefaults;
|
||||
messageProvider?: string;
|
||||
@@ -532,17 +511,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
sessionKey?: string;
|
||||
agentDir?: string;
|
||||
config?: ClawdbotConfig;
|
||||
abortSignal?: AbortSignal;
|
||||
/**
|
||||
* Provider of the currently selected model (used for provider-specific tool quirks).
|
||||
* Example: "anthropic", "openai", "google", "openai-codex".
|
||||
*/
|
||||
modelProvider?: string;
|
||||
/**
|
||||
* Auth mode for the current provider. We only need this for Anthropic OAuth
|
||||
* tool-name blocking quirks.
|
||||
*/
|
||||
modelAuthMode?: ModelAuthMode;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
@@ -639,14 +607,8 @@ export function createClawdbotCodingTools(options?: {
|
||||
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
|
||||
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
|
||||
const normalized = subagentFiltered.map(normalizeToolParameters);
|
||||
const withAbort = options?.abortSignal
|
||||
? normalized.map((tool) =>
|
||||
wrapToolWithAbortSignal(tool, options.abortSignal),
|
||||
)
|
||||
: normalized;
|
||||
|
||||
// NOTE: Keep canonical (lowercase) tool names here.
|
||||
// pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names
|
||||
// on the wire and maps them back for tool dispatch.
|
||||
return withAbort;
|
||||
// Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
|
||||
// Always use capitalized versions for compatibility with both OAuth and regular API keys.
|
||||
return renameBlockedToolsForOAuth(normalized);
|
||||
}
|
||||
@@ -55,8 +55,6 @@ export type SandboxBrowserConfig = {
|
||||
noVncPort: number;
|
||||
headless: boolean;
|
||||
enableNoVnc: boolean;
|
||||
autoStart: boolean;
|
||||
autoStartTimeoutMs: number;
|
||||
};
|
||||
|
||||
export type SandboxDockerConfig = {
|
||||
@@ -161,7 +159,6 @@ const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdbot-sbx-browser-";
|
||||
const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
|
||||
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
|
||||
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;
|
||||
const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000;
|
||||
const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
|
||||
|
||||
const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox");
|
||||
@@ -310,38 +307,9 @@ export function resolveSandboxBrowserConfig(params: {
|
||||
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
||||
enableNoVnc:
|
||||
agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
|
||||
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
|
||||
autoStartTimeoutMs:
|
||||
agentBrowser?.autoStartTimeoutMs ??
|
||||
globalBrowser?.autoStartTimeoutMs ??
|
||||
DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSandboxCdp(params: {
|
||||
cdpPort: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<boolean> {
|
||||
const deadline = Date.now() + Math.max(0, params.timeoutMs);
|
||||
const url = `http://127.0.0.1:${params.cdpPort}/json/version`;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 1000);
|
||||
try {
|
||||
const res = await fetch(url, { signal: ctrl.signal });
|
||||
if (res.ok) return true;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveSandboxPruneConfig(params: {
|
||||
scope: SandboxScope;
|
||||
globalPrune?: Partial<SandboxPruneConfig>;
|
||||
@@ -953,31 +921,12 @@ async function ensureSandboxBrowser(params: {
|
||||
if (shouldReuse && existing) {
|
||||
bridge = existing.bridge;
|
||||
} else {
|
||||
const onEnsureAttachTarget = params.cfg.browser.autoStart
|
||||
? async () => {
|
||||
const state = await dockerContainerState(containerName);
|
||||
if (state.exists && !state.running) {
|
||||
await execDocker(["start", containerName]);
|
||||
}
|
||||
const ok = await waitForSandboxCdp({
|
||||
cdpPort: mappedCdp,
|
||||
timeoutMs: params.cfg.browser.autoStartTimeoutMs,
|
||||
});
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
`Sandbox browser CDP did not become reachable on 127.0.0.1:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
bridge = await startBrowserBridgeServer({
|
||||
resolved: buildSandboxBrowserResolvedConfig({
|
||||
controlPort: 0,
|
||||
cdpPort: mappedCdp,
|
||||
headless: params.cfg.browser.headless,
|
||||
}),
|
||||
onEnsureAttachTarget,
|
||||
});
|
||||
}
|
||||
if (!shouldReuse) {
|
||||
|
||||
@@ -46,21 +46,6 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).toContain("sessions_send");
|
||||
});
|
||||
|
||||
it("preserves tool casing in the prompt", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
toolNames: ["Read", "Bash", "process"],
|
||||
skillsPrompt:
|
||||
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("- Read: Read file contents");
|
||||
expect(prompt).toContain("- Bash: Run shell commands");
|
||||
expect(prompt).toContain(
|
||||
"Use `Read` to load the SKILL.md at the location listed for that skill.",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes user time when provided", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
|
||||
@@ -84,19 +84,9 @@ export function buildAgentSystemPrompt(params: {
|
||||
"image",
|
||||
];
|
||||
|
||||
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
|
||||
const canonicalToolNames = rawToolNames.filter(Boolean);
|
||||
const canonicalByNormalized = new Map<string, string>();
|
||||
for (const name of canonicalToolNames) {
|
||||
const normalized = name.toLowerCase();
|
||||
if (!canonicalByNormalized.has(normalized)) {
|
||||
canonicalByNormalized.set(normalized, name);
|
||||
}
|
||||
}
|
||||
const resolveToolName = (normalized: string) =>
|
||||
canonicalByNormalized.get(normalized) ?? normalized;
|
||||
|
||||
const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase());
|
||||
const normalizedTools = (params.toolNames ?? [])
|
||||
.map((tool) => tool.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const availableTools = new Set(normalizedTools);
|
||||
const extraTools = Array.from(
|
||||
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
|
||||
@@ -104,17 +94,13 @@ export function buildAgentSystemPrompt(params: {
|
||||
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
|
||||
const toolLines = enabledTools.map((tool) => {
|
||||
const summary = toolSummaries[tool];
|
||||
const name = resolveToolName(tool);
|
||||
return summary ? `- ${name}: ${summary}` : `- ${name}`;
|
||||
return summary ? `- ${tool}: ${summary}` : `- ${tool}`;
|
||||
});
|
||||
for (const tool of extraTools.sort()) {
|
||||
toolLines.push(`- ${resolveToolName(tool)}`);
|
||||
toolLines.push(`- ${tool}`);
|
||||
}
|
||||
|
||||
const hasGateway = availableTools.has("gateway");
|
||||
const readToolName = resolveToolName("read");
|
||||
const bashToolName = resolveToolName("bash");
|
||||
const processToolName = resolveToolName("process");
|
||||
const extraSystemPrompt = params.extraSystemPrompt?.trim();
|
||||
const ownerNumbers = (params.ownerNumbers ?? [])
|
||||
.map((value) => value.trim())
|
||||
@@ -157,7 +143,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
const skillsSection = skillsPrompt
|
||||
? [
|
||||
"## Skills",
|
||||
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
|
||||
"Skills provide task-specific instructions. Use `read` to load the SKILL.md at the location listed for that skill.",
|
||||
...skillsLines,
|
||||
"",
|
||||
]
|
||||
@@ -168,7 +154,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
"",
|
||||
"## Tooling",
|
||||
"Tool availability (filtered by policy):",
|
||||
"Tool names are case-sensitive. Call tools exactly as listed.",
|
||||
toolLines.length > 0
|
||||
? toolLines.join("\n")
|
||||
: [
|
||||
@@ -176,8 +161,8 @@ export function buildAgentSystemPrompt(params: {
|
||||
"- grep: search file contents for patterns",
|
||||
"- find: find files by glob pattern",
|
||||
"- ls: list directory contents",
|
||||
`- ${bashToolName}: run shell commands (supports background via yieldMs/background)`,
|
||||
`- ${processToolName}: manage background bash sessions`,
|
||||
"- bash: run shell commands (supports background via yieldMs/background)",
|
||||
"- process: manage background bash sessions",
|
||||
"- whatsapp_login: generate a WhatsApp QR code and wait for linking",
|
||||
"- browser: control clawd's dedicated browser",
|
||||
"- canvas: present/eval/snapshot the Canvas",
|
||||
|
||||
@@ -37,10 +37,20 @@ const GatewayToolSchema = Type.Object({
|
||||
note: Type.Optional(Type.String()),
|
||||
restartDelayMs: Type.Optional(Type.Number()),
|
||||
});
|
||||
// NOTE: We intentionally avoid top-level `allOf`/`anyOf`/`oneOf` conditionals here:
|
||||
// - OpenAI rejects tool schemas that include these keywords at the *top-level*.
|
||||
// - Claude/Vertex has other JSON Schema quirks.
|
||||
// Conditional requirements (like `raw` for config.apply) are enforced at runtime.
|
||||
// Keep top-level object schemas while enforcing conditional requirements.
|
||||
(GatewayToolSchema as typeof GatewayToolSchema & { allOf?: unknown[] }).allOf =
|
||||
[
|
||||
{
|
||||
if: {
|
||||
properties: {
|
||||
action: { const: "config.apply" },
|
||||
},
|
||||
required: ["action"],
|
||||
},
|
||||
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword.
|
||||
then: { required: ["raw"] },
|
||||
},
|
||||
];
|
||||
|
||||
export function createGatewayTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
|
||||
@@ -136,13 +136,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
description: "Show current status.",
|
||||
textAlias: "/status",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "config",
|
||||
nativeName: "config",
|
||||
description: "Show or set config values.",
|
||||
textAlias: "/config",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "debug",
|
||||
nativeName: "debug",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentDir,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveSessionAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { resolveModelRefFromString } from "../agents/model-selection.js";
|
||||
import {
|
||||
@@ -250,10 +250,7 @@ export async function getReplyFromConfig(
|
||||
configOverride?: ClawdbotConfig,
|
||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: ctx.SessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
|
||||
const agentCfg = cfg.agents?.defaults;
|
||||
const sessionCfg = cfg.session;
|
||||
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import {
|
||||
normalizeCommandBody,
|
||||
shouldHandleTextCommands,
|
||||
} from "../commands-registry.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
|
||||
const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
|
||||
const ABORT_MEMORY = new Map<string, boolean>();
|
||||
|
||||
@@ -32,83 +14,3 @@ export function getAbortMemory(key: string): boolean | undefined {
|
||||
export function setAbortMemory(key: string, value: boolean): void {
|
||||
ABORT_MEMORY.set(key, value);
|
||||
}
|
||||
|
||||
function resolveSessionEntryForKey(
|
||||
store: Record<string, SessionEntry> | undefined,
|
||||
sessionKey: string | undefined,
|
||||
) {
|
||||
if (!store || !sessionKey) return {};
|
||||
const direct = store[sessionKey];
|
||||
if (direct) return { entry: direct, key: sessionKey };
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
const legacyKey = parsed?.rest;
|
||||
if (legacyKey && store[legacyKey]) {
|
||||
return { entry: store[legacyKey], key: legacyKey };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveAbortTargetKey(ctx: MsgContext): string | undefined {
|
||||
const target = ctx.CommandTargetSessionKey?.trim();
|
||||
if (target) return target;
|
||||
const sessionKey = ctx.SessionKey?.trim();
|
||||
return sessionKey || undefined;
|
||||
}
|
||||
|
||||
export async function tryFastAbortFromMessage(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
}): Promise<{ handled: boolean; aborted: boolean }> {
|
||||
const { ctx, cfg } = params;
|
||||
const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase();
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
if (!allowTextCommands) return { handled: false, aborted: false };
|
||||
|
||||
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
});
|
||||
if (!auth.isAuthorizedSender) return { handled: false, aborted: false };
|
||||
|
||||
const targetKey = resolveAbortTargetKey(ctx);
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: targetKey ?? ctx.SessionKey ?? "",
|
||||
config: cfg,
|
||||
});
|
||||
const raw = stripStructuralPrefixes(ctx.Body ?? "");
|
||||
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
|
||||
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
||||
const normalized = normalizeCommandBody(stripped);
|
||||
const abortRequested = normalized === "/stop" || isAbortTrigger(stripped);
|
||||
if (!abortRequested) return { handled: false, aborted: false };
|
||||
|
||||
const abortKey = targetKey ?? auth.from ?? auth.to;
|
||||
|
||||
if (targetKey) {
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const { entry, key } = resolveSessionEntryForKey(store, targetKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false;
|
||||
if (entry && key) {
|
||||
entry.abortedLastRun = true;
|
||||
entry.updatedAt = Date.now();
|
||||
store[key] = entry;
|
||||
await saveSessionStore(storePath, store);
|
||||
} else if (abortKey) {
|
||||
setAbortMemory(abortKey, true);
|
||||
}
|
||||
return { handled: true, aborted };
|
||||
}
|
||||
|
||||
if (abortKey) {
|
||||
setAbortMemory(abortKey, true);
|
||||
}
|
||||
return { handled: true, aborted: false };
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
emitAgentEvent,
|
||||
registerAgentRunContext,
|
||||
} from "../../infra/agent-events.js";
|
||||
import { isAudioFileName } from "../../media/mime.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
estimateUsageCost,
|
||||
@@ -33,12 +32,10 @@ import {
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import {
|
||||
createAudioAsVoiceBuffer,
|
||||
createBlockReplyPipeline,
|
||||
} from "./block-reply-pipeline.js";
|
||||
import { extractAudioTag } from "./audio-tags.js";
|
||||
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
import {
|
||||
@@ -47,7 +44,6 @@ import {
|
||||
type QueueSettings,
|
||||
scheduleFollowupDrain,
|
||||
} from "./queue.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import {
|
||||
applyReplyTagsToPayload,
|
||||
applyReplyThreading,
|
||||
@@ -264,13 +260,6 @@ export async function runReplyAgent(params: {
|
||||
const pendingToolTasks = new Set<Promise<void>>();
|
||||
const blockReplyTimeoutMs =
|
||||
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
|
||||
|
||||
const hasAudioMedia = (urls?: string[]): boolean =>
|
||||
Boolean(urls?.some((u) => isAudioFileName(u)));
|
||||
const isAudioPayload = (payload: ReplyPayload) =>
|
||||
hasAudioMedia(
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
|
||||
);
|
||||
const replyToChannel =
|
||||
sessionCtx.OriginatingChannel ??
|
||||
((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as
|
||||
@@ -300,7 +289,6 @@ export async function runReplyAgent(params: {
|
||||
onBlockReply: opts.onBlockReply,
|
||||
timeoutMs: blockReplyTimeoutMs,
|
||||
coalescing: blockReplyCoalescing,
|
||||
buffer: createAudioAsVoiceBuffer({ isAudioPayload }),
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -544,53 +532,30 @@ export async function runReplyAgent(params: {
|
||||
},
|
||||
sessionCtx.MessageSid,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (
|
||||
!isRenderablePayload(taggedPayload) &&
|
||||
!payload.audioAsVoice
|
||||
)
|
||||
return;
|
||||
const parsed = parseReplyDirectives(
|
||||
taggedPayload.text ?? "",
|
||||
{
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
},
|
||||
);
|
||||
const cleaned = parsed.text || undefined;
|
||||
if (!isRenderablePayload(taggedPayload)) return;
|
||||
const audioTagResult = extractAudioTag(taggedPayload.text);
|
||||
const cleaned = audioTagResult.cleaned || undefined;
|
||||
const hasMedia =
|
||||
Boolean(taggedPayload.mediaUrl) ||
|
||||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||
if (!cleaned && !hasMedia) return;
|
||||
if (
|
||||
!cleaned &&
|
||||
!hasMedia &&
|
||||
!payload.audioAsVoice &&
|
||||
!parsed.audioAsVoice
|
||||
isSilentReplyText(cleaned, SILENT_REPLY_TOKEN) &&
|
||||
!hasMedia
|
||||
)
|
||||
return;
|
||||
if (parsed.isSilent && !hasMedia) return;
|
||||
|
||||
const blockPayload: ReplyPayload = applyReplyToMode({
|
||||
...taggedPayload,
|
||||
text: cleaned,
|
||||
audioAsVoice: Boolean(
|
||||
parsed.audioAsVoice || payload.audioAsVoice,
|
||||
),
|
||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent:
|
||||
taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
audioAsVoice: audioTagResult.audioAsVoice,
|
||||
});
|
||||
|
||||
void typingSignals
|
||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||
.signalTextDelta(taggedPayload.text)
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`block reply typing signal failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
blockReplyPipeline?.enqueue(blockPayload);
|
||||
}
|
||||
: undefined,
|
||||
@@ -705,7 +670,6 @@ export async function runReplyAgent(params: {
|
||||
}
|
||||
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
|
||||
if (blockReplyPipeline) {
|
||||
await blockReplyPipeline.flush({ force: true });
|
||||
blockReplyPipeline.stop();
|
||||
@@ -713,7 +677,6 @@ export async function runReplyAgent(params: {
|
||||
if (pendingToolTasks.size > 0) {
|
||||
await Promise.allSettled(pendingToolTasks);
|
||||
}
|
||||
|
||||
// Drain any late tool/block deliveries before deciding there's "nothing to send".
|
||||
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
|
||||
// keep the typing indicator stuck.
|
||||
@@ -748,21 +711,11 @@ export async function runReplyAgent(params: {
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
})
|
||||
.map((payload) => {
|
||||
const parsed = parseReplyDirectives(payload.text ?? "", {
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
});
|
||||
const mediaUrls = payload.mediaUrls ?? parsed.mediaUrls;
|
||||
const mediaUrl = payload.mediaUrl ?? parsed.mediaUrl ?? mediaUrls?.[0];
|
||||
const audioTagResult = extractAudioTag(payload.text);
|
||||
return {
|
||||
...payload,
|
||||
text: parsed.text ? parsed.text : undefined,
|
||||
mediaUrls,
|
||||
mediaUrl,
|
||||
replyToId: payload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: payload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent,
|
||||
audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice),
|
||||
text: audioTagResult.cleaned ? audioTagResult.cleaned : undefined,
|
||||
audioAsVoice: audioTagResult.audioAsVoice,
|
||||
};
|
||||
})
|
||||
.filter(isRenderablePayload);
|
||||
@@ -798,7 +751,8 @@ export async function runReplyAgent(params: {
|
||||
|
||||
const shouldSignalTyping = replyPayloads.some((payload) => {
|
||||
const trimmed = payload.text?.trim();
|
||||
if (trimmed) return true;
|
||||
if (trimmed && !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN))
|
||||
return true;
|
||||
if (payload.mediaUrl) return true;
|
||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||
return false;
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseAudioTag } from "./audio-tags.js";
|
||||
import { extractAudioTag } from "./audio-tags.js";
|
||||
|
||||
describe("parseAudioTag", () => {
|
||||
describe("extractAudioTag", () => {
|
||||
it("detects audio_as_voice and strips the tag", () => {
|
||||
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
||||
const result = extractAudioTag("Hello [[audio_as_voice]] world");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.hadTag).toBe(true);
|
||||
expect(result.text).toBe("Hello world");
|
||||
expect(result.hasTag).toBe(true);
|
||||
expect(result.cleaned).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("returns empty output for missing text", () => {
|
||||
const result = parseAudioTag(undefined);
|
||||
const result = extractAudioTag(undefined);
|
||||
expect(result.audioAsVoice).toBe(false);
|
||||
expect(result.hadTag).toBe(false);
|
||||
expect(result.text).toBe("");
|
||||
expect(result.hasTag).toBe(false);
|
||||
expect(result.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("removes tag-only messages", () => {
|
||||
const result = parseAudioTag("[[audio_as_voice]]");
|
||||
const result = extractAudioTag("[[audio_as_voice]]");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.text).toBe("");
|
||||
expect(result.cleaned).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1 +1,31 @@
|
||||
export { parseAudioTag } from "../../media/audio-tags.js";
|
||||
/**
|
||||
* Extract audio mode tag from text.
|
||||
* Supports [[audio_as_voice]] to send audio as voice bubble instead of file.
|
||||
* Default is file (preserves backward compatibility).
|
||||
*/
|
||||
export function extractAudioTag(text?: string): {
|
||||
cleaned: string;
|
||||
audioAsVoice: boolean;
|
||||
hasTag: boolean;
|
||||
} {
|
||||
if (!text) return { cleaned: "", audioAsVoice: false, hasTag: false };
|
||||
let cleaned = text;
|
||||
let audioAsVoice = false; // default: audio file (backward compatible)
|
||||
let hasTag = false;
|
||||
|
||||
// [[audio_as_voice]] -> send as voice bubble (opt-in)
|
||||
const voiceMatch = cleaned.match(/\[\[audio_as_voice\]\]/i);
|
||||
if (voiceMatch) {
|
||||
cleaned = cleaned.replace(/\[\[audio_as_voice\]\]/gi, " ");
|
||||
audioAsVoice = true;
|
||||
hasTag = true;
|
||||
}
|
||||
|
||||
// Clean up whitespace
|
||||
cleaned = cleaned
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||
.trim();
|
||||
|
||||
return { cleaned, audioAsVoice, hasTag };
|
||||
}
|
||||
@@ -13,28 +13,6 @@ export type BlockReplyPipeline = {
|
||||
hasSentPayload: (payload: ReplyPayload) => boolean;
|
||||
};
|
||||
|
||||
export type BlockReplyBuffer = {
|
||||
shouldBuffer: (payload: ReplyPayload) => boolean;
|
||||
onEnqueue?: (payload: ReplyPayload) => void;
|
||||
finalize?: (payload: ReplyPayload) => ReplyPayload;
|
||||
};
|
||||
|
||||
export function createAudioAsVoiceBuffer(params: {
|
||||
isAudioPayload: (payload: ReplyPayload) => boolean;
|
||||
}): BlockReplyBuffer {
|
||||
let seenAudioAsVoice = false;
|
||||
return {
|
||||
onEnqueue: (payload) => {
|
||||
if (payload.audioAsVoice) {
|
||||
seenAudioAsVoice = true;
|
||||
}
|
||||
},
|
||||
shouldBuffer: (payload) => params.isAudioPayload(payload),
|
||||
finalize: (payload) =>
|
||||
seenAudioAsVoice ? { ...payload, audioAsVoice: true } : payload,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlockReplyPayloadKey(payload: ReplyPayload): string {
|
||||
const text = payload.text?.trim() ?? "";
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
@@ -73,15 +51,12 @@ export function createBlockReplyPipeline(params: {
|
||||
) => Promise<void> | void;
|
||||
timeoutMs: number;
|
||||
coalescing?: BlockStreamingCoalescing;
|
||||
buffer?: BlockReplyBuffer;
|
||||
}): BlockReplyPipeline {
|
||||
const { onBlockReply, timeoutMs, coalescing, buffer } = params;
|
||||
const { onBlockReply, timeoutMs, coalescing } = params;
|
||||
const sentKeys = new Set<string>();
|
||||
const pendingKeys = new Set<string>();
|
||||
const seenKeys = new Set<string>();
|
||||
const bufferedKeys = new Set<string>();
|
||||
const bufferedPayloadKeys = new Set<string>();
|
||||
const bufferedPayloads: ReplyPayload[] = [];
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
let aborted = false;
|
||||
let didStream = false;
|
||||
@@ -149,37 +124,8 @@ export function createBlockReplyPipeline(params: {
|
||||
})
|
||||
: null;
|
||||
|
||||
const bufferPayload = (payload: ReplyPayload) => {
|
||||
buffer?.onEnqueue?.(payload);
|
||||
if (!buffer?.shouldBuffer(payload)) return false;
|
||||
const payloadKey = createBlockReplyPayloadKey(payload);
|
||||
if (
|
||||
seenKeys.has(payloadKey) ||
|
||||
sentKeys.has(payloadKey) ||
|
||||
pendingKeys.has(payloadKey) ||
|
||||
bufferedPayloadKeys.has(payloadKey)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
seenKeys.add(payloadKey);
|
||||
bufferedPayloadKeys.add(payloadKey);
|
||||
bufferedPayloads.push(payload);
|
||||
return true;
|
||||
};
|
||||
|
||||
const flushBuffered = () => {
|
||||
if (!bufferedPayloads.length) return;
|
||||
for (const payload of bufferedPayloads) {
|
||||
const finalPayload = buffer?.finalize?.(payload) ?? payload;
|
||||
sendPayload(finalPayload, true);
|
||||
}
|
||||
bufferedPayloads.length = 0;
|
||||
bufferedPayloadKeys.clear();
|
||||
};
|
||||
|
||||
const enqueue = (payload: ReplyPayload) => {
|
||||
if (aborted) return;
|
||||
if (bufferPayload(payload)) return;
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (hasMedia) {
|
||||
@@ -205,7 +151,6 @@ export function createBlockReplyPipeline(params: {
|
||||
|
||||
const flush = async (options?: { force?: boolean }) => {
|
||||
await coalescer?.flush(options);
|
||||
flushBuffered();
|
||||
await sendChain;
|
||||
};
|
||||
|
||||
@@ -217,8 +162,7 @@ export function createBlockReplyPipeline(params: {
|
||||
enqueue,
|
||||
flush,
|
||||
stop,
|
||||
hasBuffered: () =>
|
||||
Boolean(coalescer?.hasBuffered() || bufferedPayloads.length > 0),
|
||||
hasBuffered: () => Boolean(coalescer?.hasBuffered()),
|
||||
didStream: () => didStream,
|
||||
isAborted: () => aborted,
|
||||
hasSentPayload: (payload) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveDefaultAgentId,
|
||||
resolveSessionAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
@@ -20,17 +19,6 @@ import {
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
readConfigFileSnapshot,
|
||||
validateConfigObject,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import {
|
||||
getConfigValueAtPath,
|
||||
parseConfigPath,
|
||||
setConfigValueAtPath,
|
||||
unsetConfigValueAtPath,
|
||||
} from "../../config/config-paths.js";
|
||||
import {
|
||||
getConfigOverrides,
|
||||
resetConfigOverrides,
|
||||
@@ -38,6 +26,7 @@ import {
|
||||
unsetConfigOverride,
|
||||
} from "../../config/runtime-overrides.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionFilePath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
@@ -83,7 +72,6 @@ import type {
|
||||
} from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
@@ -160,7 +148,7 @@ export async function buildStatusReply(params: {
|
||||
return undefined;
|
||||
}
|
||||
const statusAgentId = sessionKey
|
||||
? resolveSessionAgentId({ sessionKey, config: cfg })
|
||||
? resolveAgentIdFromSessionKey(sessionKey)
|
||||
: resolveDefaultAgentId(cfg);
|
||||
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
|
||||
let usageLine: string | null = null;
|
||||
@@ -640,130 +628,6 @@ export async function handleCommands(params: {
|
||||
return { shouldContinue: false, reply };
|
||||
}
|
||||
|
||||
const configCommand = allowTextCommands
|
||||
? parseConfigCommand(command.commandBodyNormalized)
|
||||
: null;
|
||||
if (configCommand) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /config from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (configCommand.action === "error") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${configCommand.message}` },
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (
|
||||
!snapshot.valid ||
|
||||
!snapshot.parsed ||
|
||||
typeof snapshot.parsed !== "object"
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ Config file is invalid; fix it before using /config.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const parsedBase = structuredClone(
|
||||
snapshot.parsed as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (configCommand.action === "show") {
|
||||
const pathRaw = configCommand.path?.trim();
|
||||
if (pathRaw) {
|
||||
const parsedPath = parseConfigPath(pathRaw);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
const value = getConfigValueAtPath(parsedBase, parsedPath.path);
|
||||
const rendered = JSON.stringify(value ?? null, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Config ${pathRaw}:\n\`\`\`json\n${rendered}\n\`\`\``,
|
||||
},
|
||||
};
|
||||
}
|
||||
const json = JSON.stringify(parsedBase, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Config (raw):\n\`\`\`json\n${json}\n\`\`\`` },
|
||||
};
|
||||
}
|
||||
|
||||
if (configCommand.action === "unset") {
|
||||
const parsedPath = parseConfigPath(configCommand.path);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path);
|
||||
if (!removed) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ No config value found for ${configCommand.path}.` },
|
||||
};
|
||||
}
|
||||
const validated = validateConfigObject(parsedBase);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Config invalid after unset (${issue.path}: ${issue.message}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` },
|
||||
};
|
||||
}
|
||||
|
||||
if (configCommand.action === "set") {
|
||||
const parsedPath = parseConfigPath(configCommand.path);
|
||||
if (!parsedPath.ok || !parsedPath.path) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||
};
|
||||
}
|
||||
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
|
||||
const validated = validateConfigObject(parsedBase);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
const valueLabel =
|
||||
typeof configCommand.value === "string"
|
||||
? `"${configCommand.value}"`
|
||||
: JSON.stringify(configCommand.value);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Config updated: ${configCommand.path}=${valueLabel ?? "null"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const debugCommand = allowTextCommands
|
||||
? parseDebugCommand(command.commandBodyNormalized)
|
||||
: null;
|
||||
@@ -789,13 +653,11 @@ export async function handleCommands(params: {
|
||||
reply: { text: "⚙️ Debug overrides: (none)" },
|
||||
};
|
||||
}
|
||||
const effectiveConfig = cfg ?? {};
|
||||
const json = JSON.stringify(overrides, null, 2);
|
||||
const effectiveJson = JSON.stringify(effectiveConfig, null, 2);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\`\n⚙️ Effective config (with overrides):\n\`\`\`json\n${effectiveJson}\n\`\`\``,
|
||||
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
|
||||
describe("parseConfigCommand", () => {
|
||||
it("parses show/unset", () => {
|
||||
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
||||
expect(parseConfigCommand("/config show")).toEqual({
|
||||
action: "show",
|
||||
path: undefined,
|
||||
});
|
||||
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
||||
action: "unset",
|
||||
path: "foo.bar",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { parseConfigValue } from "./config-value.js";
|
||||
|
||||
export type ConfigCommand =
|
||||
| { action: "show"; path?: string }
|
||||
| { action: "set"; path: string; value: unknown }
|
||||
| { action: "unset"; path: string }
|
||||
| { action: "error"; message: string };
|
||||
|
||||
export function parseConfigCommand(raw: string): ConfigCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/config")) return null;
|
||||
const rest = trimmed.slice("/config".length).trim();
|
||||
if (!rest) return { action: "show" };
|
||||
|
||||
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) return { action: "error", message: "Invalid /config syntax." };
|
||||
const action = match[1].toLowerCase();
|
||||
const args = (match[2] ?? "").trim();
|
||||
|
||||
switch (action) {
|
||||
case "show":
|
||||
return { action: "show", path: args || undefined };
|
||||
case "get":
|
||||
return { action: "show", path: args || undefined };
|
||||
case "unset": {
|
||||
if (!args)
|
||||
return { action: "error", message: "Usage: /config unset path" };
|
||||
return { action: "unset", path: args };
|
||||
}
|
||||
case "set": {
|
||||
if (!args) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config set path=value",
|
||||
};
|
||||
}
|
||||
const eqIndex = args.indexOf("=");
|
||||
if (eqIndex <= 0) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config set path=value",
|
||||
};
|
||||
}
|
||||
const path = args.slice(0, eqIndex).trim();
|
||||
const rawValue = args.slice(eqIndex + 1);
|
||||
if (!path) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config set path=value",
|
||||
};
|
||||
}
|
||||
const parsed = parseConfigValue(rawValue);
|
||||
if (parsed.error) {
|
||||
return { action: "error", message: parsed.error };
|
||||
}
|
||||
return { action: "set", path, value: parsed.value };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config show|set|unset",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
export function parseConfigValue(raw: string): {
|
||||
value?: unknown;
|
||||
error?: string;
|
||||
} {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { error: "Missing value." };
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch (err) {
|
||||
return { error: `Invalid JSON: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === "true") return { value: true };
|
||||
if (trimmed === "false") return { value: false };
|
||||
if (trimmed === "null") return { value: null };
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (Number.isFinite(num)) return { value: num };
|
||||
}
|
||||
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
const unquoted = trimmed.slice(1, -1);
|
||||
return { value: unquoted };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: trimmed };
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { parseConfigValue } from "./config-value.js";
|
||||
|
||||
export type DebugCommand =
|
||||
| { action: "show" }
|
||||
| { action: "reset" }
|
||||
@@ -7,6 +5,42 @@ export type DebugCommand =
|
||||
| { action: "unset"; path: string }
|
||||
| { action: "error"; message: string };
|
||||
|
||||
function parseDebugValue(raw: string): { value?: unknown; error?: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { error: "Missing value." };
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch (err) {
|
||||
return { error: `Invalid JSON: ${String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === "true") return { value: true };
|
||||
if (trimmed === "false") return { value: false };
|
||||
if (trimmed === "null") return { value: null };
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (Number.isFinite(num)) return { value: num };
|
||||
}
|
||||
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
try {
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
const unquoted = trimmed.slice(1, -1);
|
||||
return { value: unquoted };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: trimmed };
|
||||
}
|
||||
|
||||
export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/debug")) return null;
|
||||
@@ -50,7 +84,7 @@ export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
message: "Usage: /debug set path=value",
|
||||
};
|
||||
}
|
||||
const parsed = parseConfigValue(rawValue);
|
||||
const parsed = parseDebugValue(rawValue);
|
||||
if (parsed.error) {
|
||||
return { action: "error", message: parsed.error };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentDir,
|
||||
resolveDefaultAgentId,
|
||||
resolveSessionAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import {
|
||||
isProfileInCooldown,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
@@ -491,18 +491,14 @@ export async function handleDirectiveOnly(params: {
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
} = params;
|
||||
const activeAgentId = resolveSessionAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.cfg,
|
||||
});
|
||||
const activeAgentId = params.sessionKey
|
||||
? resolveAgentIdFromSessionKey(params.sessionKey)
|
||||
: resolveDefaultAgentId(params.cfg);
|
||||
const agentDir = resolveAgentDir(params.cfg, activeAgentId);
|
||||
const runtimeIsSandboxed = (() => {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey) return false;
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey,
|
||||
config: params.cfg,
|
||||
});
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId);
|
||||
if (sandboxCfg.mode === "off") return false;
|
||||
const mainKey = resolveAgentMainSessionKey({
|
||||
@@ -1017,7 +1013,7 @@ export async function persistInlineDirectives(params: {
|
||||
} = params;
|
||||
let { provider, model } = params;
|
||||
const activeAgentId = sessionKey
|
||||
? resolveSessionAgentId({ sessionKey, config: cfg })
|
||||
? resolveAgentIdFromSessionKey(sessionKey)
|
||||
: resolveDefaultAgentId(cfg);
|
||||
const agentDir = resolveAgentDir(cfg, activeAgentId);
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@ import type { ReplyDispatcher } from "./reply-dispatcher.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
routeReply: vi.fn(async () => ({ ok: true, messageId: "mock" })),
|
||||
tryFastAbortFromMessage: vi.fn(async () => ({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./route-reply.js", () => ({
|
||||
@@ -29,10 +25,6 @@ vi.mock("./route-reply.js", () => ({
|
||||
routeReply: mocks.routeReply,
|
||||
}));
|
||||
|
||||
vi.mock("./abort.js", () => ({
|
||||
tryFastAbortFromMessage: mocks.tryFastAbortFromMessage,
|
||||
}));
|
||||
|
||||
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
||||
|
||||
function createDispatcher(): ReplyDispatcher {
|
||||
@@ -47,10 +39,6 @@ function createDispatcher(): ReplyDispatcher {
|
||||
|
||||
describe("dispatchReplyFromConfig", () => {
|
||||
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
});
|
||||
mocks.routeReply.mockClear();
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
@@ -72,10 +60,6 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
|
||||
it("routes when OriginatingChannel differs from Provider", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
});
|
||||
mocks.routeReply.mockClear();
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
@@ -104,25 +88,4 @@ describe("dispatchReplyFromConfig", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fast-aborts without calling the reply resolver", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: true,
|
||||
aborted: true,
|
||||
});
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx: MsgContext = {
|
||||
Provider: "telegram",
|
||||
Body: "/stop",
|
||||
};
|
||||
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(replyResolver).not.toHaveBeenCalled();
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
|
||||
text: "⚙️ Agent was aborted.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,10 @@ import { logVerbose } from "../../globals.js";
|
||||
import { getReplyFromConfig } from "../reply.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { tryFastAbortFromMessage } from "./abort.js";
|
||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||
|
||||
export type DispatchFromConfigResult = {
|
||||
type DispatchFromConfigResult = {
|
||||
queuedFinal: boolean;
|
||||
counts: Record<ReplyDispatchKind, number>;
|
||||
};
|
||||
@@ -67,37 +66,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
}
|
||||
};
|
||||
|
||||
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
|
||||
if (fastAbort.handled) {
|
||||
const payload = { text: "⚙️ Agent was aborted." } satisfies ReplyPayload;
|
||||
let queuedFinal = false;
|
||||
let routedFinalCount = 0;
|
||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||
const result = await routeReply({
|
||||
payload,
|
||||
channel: originatingChannel,
|
||||
to: originatingTo,
|
||||
sessionKey: ctx.SessionKey,
|
||||
accountId: ctx.AccountId,
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
});
|
||||
queuedFinal = result.ok;
|
||||
if (result.ok) routedFinalCount += 1;
|
||||
if (!result.ok) {
|
||||
logVerbose(
|
||||
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
queuedFinal = dispatcher.sendFinalReply(payload);
|
||||
}
|
||||
await dispatcher.waitForIdle();
|
||||
const counts = dispatcher.getQueuedCounts();
|
||||
counts.final += routedFinalCount;
|
||||
return { queuedFinal, counts };
|
||||
}
|
||||
|
||||
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
||||
ctx,
|
||||
{
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions } from "../types.js";
|
||||
import type { DispatchFromConfigResult } from "./dispatch-from-config.js";
|
||||
import { dispatchReplyFromConfig } from "./dispatch-from-config.js";
|
||||
import {
|
||||
createReplyDispatcherWithTyping,
|
||||
type ReplyDispatcherWithTypingOptions,
|
||||
} from "./reply-dispatcher.js";
|
||||
|
||||
export async function dispatchReplyWithBufferedBlockDispatcher(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
dispatcherOptions: ReplyDispatcherWithTypingOptions;
|
||||
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
|
||||
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
|
||||
}): Promise<DispatchFromConfigResult> {
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
createReplyDispatcherWithTyping(params.dispatcherOptions);
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: params.ctx,
|
||||
cfg: params.cfg,
|
||||
dispatcher,
|
||||
replyResolver: params.replyResolver,
|
||||
replyOptions: {
|
||||
...params.replyOptions,
|
||||
...replyOptions,
|
||||
},
|
||||
});
|
||||
|
||||
markDispatchIdle();
|
||||
return result;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { splitMediaFromOutput } from "../../media/parse.js";
|
||||
import { parseInlineDirectives } from "../../utils/directive-tags.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
|
||||
export type ReplyDirectiveParseResult = {
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
replyToCurrent: boolean;
|
||||
replyToTag: boolean;
|
||||
audioAsVoice?: boolean;
|
||||
isSilent: boolean;
|
||||
};
|
||||
|
||||
export function parseReplyDirectives(
|
||||
raw: string,
|
||||
options: { currentMessageId?: string; silentToken?: string } = {},
|
||||
): ReplyDirectiveParseResult {
|
||||
const split = splitMediaFromOutput(raw);
|
||||
let text = split.text ?? "";
|
||||
|
||||
const replyParsed = parseInlineDirectives(text, {
|
||||
currentMessageId: options.currentMessageId,
|
||||
stripAudioTag: false,
|
||||
stripReplyTags: true,
|
||||
});
|
||||
|
||||
if (replyParsed.hasReplyTag) {
|
||||
text = replyParsed.text;
|
||||
}
|
||||
|
||||
const silentToken = options.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
const isSilent = isSilentReplyText(text, silentToken);
|
||||
if (isSilent) {
|
||||
text = "";
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
mediaUrls: split.mediaUrls,
|
||||
mediaUrl: split.mediaUrl,
|
||||
replyToId: replyParsed.replyToId,
|
||||
replyToCurrent: replyParsed.replyToCurrent,
|
||||
replyToTag: replyParsed.hasReplyTag,
|
||||
audioAsVoice: split.audioAsVoice,
|
||||
isSilent,
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export type ReplyDispatcherOptions = {
|
||||
onError?: ReplyDispatchErrorHandler;
|
||||
};
|
||||
|
||||
export type ReplyDispatcherWithTypingOptions = Omit<
|
||||
type ReplyDispatcherWithTypingOptions = Omit<
|
||||
ReplyDispatcherOptions,
|
||||
"onIdle"
|
||||
> & {
|
||||
|
||||
@@ -10,23 +10,8 @@ export function applyReplyTagsToPayload(
|
||||
payload: ReplyPayload,
|
||||
currentMessageId?: string,
|
||||
): ReplyPayload {
|
||||
if (typeof payload.text !== "string") {
|
||||
if (!payload.replyToCurrent || payload.replyToId) return payload;
|
||||
return {
|
||||
...payload,
|
||||
replyToId: currentMessageId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
const shouldParseTags = payload.text.includes("[[");
|
||||
if (!shouldParseTags) {
|
||||
if (!payload.replyToCurrent || payload.replyToId) return payload;
|
||||
return {
|
||||
...payload,
|
||||
replyToId: currentMessageId?.trim() || undefined,
|
||||
replyToTag: payload.replyToTag ?? true,
|
||||
};
|
||||
}
|
||||
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
|
||||
if (typeof payload.text !== "string") return payload;
|
||||
const { cleaned, replyToId, hasTag } = extractReplyToTag(
|
||||
payload.text,
|
||||
currentMessageId,
|
||||
);
|
||||
@@ -35,7 +20,6 @@ export function applyReplyTagsToPayload(
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? payload.replyToId,
|
||||
replyToTag: hasTag || payload.replyToTag,
|
||||
replyToCurrent: replyToCurrent || payload.replyToCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { parseInlineDirectives } from "../../utils/directive-tags.js";
|
||||
const REPLY_TAG_RE =
|
||||
/\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
|
||||
|
||||
function normalizeReplyText(text: string) {
|
||||
return text
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function extractReplyToTag(
|
||||
text?: string,
|
||||
@@ -6,17 +14,31 @@ export function extractReplyToTag(
|
||||
): {
|
||||
cleaned: string;
|
||||
replyToId?: string;
|
||||
replyToCurrent: boolean;
|
||||
hasTag: boolean;
|
||||
} {
|
||||
const result = parseInlineDirectives(text, {
|
||||
currentMessageId,
|
||||
stripAudioTag: false,
|
||||
});
|
||||
return {
|
||||
cleaned: result.text,
|
||||
replyToId: result.replyToId,
|
||||
replyToCurrent: result.replyToCurrent,
|
||||
hasTag: result.hasReplyTag,
|
||||
};
|
||||
if (!text) return { cleaned: "", hasTag: false };
|
||||
|
||||
let sawCurrent = false;
|
||||
let lastExplicitId: string | undefined;
|
||||
let hasTag = false;
|
||||
|
||||
const cleaned = normalizeReplyText(
|
||||
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
|
||||
hasTag = true;
|
||||
if (idRaw === undefined) {
|
||||
sawCurrent = true;
|
||||
return " ";
|
||||
}
|
||||
|
||||
const id = idRaw.trim();
|
||||
if (id) lastExplicitId = id;
|
||||
return " ";
|
||||
}),
|
||||
);
|
||||
|
||||
const replyToId =
|
||||
lastExplicitId ??
|
||||
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
|
||||
|
||||
return { cleaned, replyToId, hasTag };
|
||||
}
|
||||
@@ -7,12 +7,12 @@
|
||||
* across multiple providers.
|
||||
*/
|
||||
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { sendMessageDiscord } from "../../discord/send.js";
|
||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||
import { sendMessageMSTeams } from "../../msteams/send.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { sendMessageSignal } from "../../signal/send.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||
@@ -67,10 +67,7 @@ export async function routeReply(
|
||||
const responsePrefix = params.sessionKey
|
||||
? resolveEffectiveMessagesConfig(
|
||||
cfg,
|
||||
resolveSessionAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
config: cfg,
|
||||
}),
|
||||
resolveAgentIdFromSessionKey(params.sessionKey),
|
||||
).responsePrefix
|
||||
: cfg.messages?.responsePrefix === "auto"
|
||||
? undefined
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CURRENT_SESSION_VERSION,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
DEFAULT_RESET_TRIGGERS,
|
||||
type GroupKeyResolution,
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveGroupSessionKey,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionKey,
|
||||
@@ -92,10 +92,7 @@ export async function initSessionState(params: {
|
||||
const { ctx, cfg, commandAuthorized } = params;
|
||||
const sessionCfg = cfg.session;
|
||||
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: ctx.SessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: DEFAULT_RESET_TRIGGERS;
|
||||
|
||||
@@ -360,7 +360,7 @@ export function buildHelpMessage(): string {
|
||||
return [
|
||||
"ℹ️ Help",
|
||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
|
||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /config show | /debug show",
|
||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /debug show",
|
||||
"More: /commands for all slash commands",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ export type ReplyPayload = {
|
||||
mediaUrls?: string[];
|
||||
replyToId?: string;
|
||||
replyToTag?: boolean;
|
||||
/** True when [[reply_to_current]] was present but not yet mapped to a message id. */
|
||||
replyToCurrent?: boolean;
|
||||
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
||||
audioAsVoice?: boolean;
|
||||
isError?: boolean;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { registerBrowserRoutes } from "./routes/index.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
type ProfileContext,
|
||||
} from "./server-context.js";
|
||||
|
||||
export type BrowserBridge = {
|
||||
@@ -21,7 +20,6 @@ export async function startBrowserBridgeServer(params: {
|
||||
resolved: ResolvedBrowserConfig;
|
||||
host?: string;
|
||||
port?: number;
|
||||
onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>;
|
||||
}): Promise<BrowserBridge> {
|
||||
const host = params.host ?? "127.0.0.1";
|
||||
const port = params.port ?? 0;
|
||||
@@ -38,7 +36,6 @@ export async function startBrowserBridgeServer(params: {
|
||||
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
onEnsureAttachTarget: params.onEnsureAttachTarget,
|
||||
});
|
||||
registerBrowserRoutes(app, ctx);
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ export type ProfileStatus = {
|
||||
|
||||
type ContextOptions = {
|
||||
getState: () => BrowserServerState | null;
|
||||
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -260,13 +259,6 @@ function createProfileContext(
|
||||
const httpReachable = await isHttpReachable();
|
||||
|
||||
if (!httpReachable) {
|
||||
if (
|
||||
(current.resolved.attachOnly || remoteCdp) &&
|
||||
opts.onEnsureAttachTarget
|
||||
) {
|
||||
await opts.onEnsureAttachTarget(profile);
|
||||
if (await isHttpReachable(1200)) return;
|
||||
}
|
||||
if (current.resolved.attachOnly || remoteCdp) {
|
||||
throw new Error(
|
||||
remoteCdp
|
||||
@@ -292,10 +284,6 @@ function createProfileContext(
|
||||
|
||||
// We own it but WebSocket failed - restart
|
||||
if (current.resolved.attachOnly || remoteCdp) {
|
||||
if (opts.onEnsureAttachTarget) {
|
||||
await opts.onEnsureAttachTarget(profile);
|
||||
if (await isReachable(1200)) return;
|
||||
}
|
||||
throw new Error(
|
||||
remoteCdp
|
||||
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
|
||||
|
||||
@@ -707,50 +707,6 @@ describe("browser control server", () => {
|
||||
expect(started.error ?? "").toMatch(/attachOnly/i);
|
||||
});
|
||||
|
||||
it("allows attachOnly servers to ensure reachability via callback", async () => {
|
||||
cfgAttachOnly = true;
|
||||
reachable = false;
|
||||
const { startBrowserBridgeServer } = await import("./bridge-server.js");
|
||||
|
||||
const ensured = vi.fn(async () => {
|
||||
reachable = true;
|
||||
});
|
||||
|
||||
const bridge = await startBrowserBridgeServer({
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:0",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 0,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
color: "#FF4500",
|
||||
headless: true,
|
||||
noSandbox: false,
|
||||
attachOnly: true,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
onEnsureAttachTarget: ensured,
|
||||
});
|
||||
|
||||
const started = (await realFetch(`${bridge.baseUrl}/start`, {
|
||||
method: "POST",
|
||||
}).then((r) => r.json())) as { ok?: boolean; error?: string };
|
||||
expect(started.error).toBeUndefined();
|
||||
expect(started.ok).toBe(true);
|
||||
const status = (await realFetch(`${bridge.baseUrl}/`).then((r) =>
|
||||
r.json(),
|
||||
)) as { running?: boolean };
|
||||
expect(status.running).toBe(true);
|
||||
expect(ensured).toHaveBeenCalledTimes(1);
|
||||
|
||||
await new Promise<void>((resolve) => bridge.server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("opens tabs via CDP createTarget path", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
} from "./google-gemini-model-default.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxConfig,
|
||||
@@ -1287,7 +1286,7 @@ export async function runConfigureWizard(
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
} catch (err) {
|
||||
runtime.error(formatHealthCheckFailure(err));
|
||||
runtime.error(`Health check failed: ${String(err)}`);
|
||||
note(
|
||||
[
|
||||
"Docs:",
|
||||
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
shouldSuggestMemorySystem,
|
||||
} from "./doctor-workspace.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import { applyWizardMetadata, printWizardHeader } from "./onboard-helpers.js";
|
||||
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
|
||||
|
||||
@@ -311,7 +310,7 @@ export async function doctorCommand(
|
||||
note("Gateway not running.", "Gateway");
|
||||
note(gatewayDetails.message, "Gateway connection");
|
||||
} else {
|
||||
runtime.error(formatHealthCheckFailure(err));
|
||||
runtime.error(`Health check failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +455,7 @@ export async function doctorCommand(
|
||||
note("Gateway not running.", "Gateway");
|
||||
note(gatewayDetails.message, "Gateway connection");
|
||||
} else {
|
||||
runtime.error(formatHealthCheckFailure(err));
|
||||
runtime.error(`Health check failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
|
||||
const stripAnsi = (input: string) =>
|
||||
input.replace(
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip ANSI escape sequences
|
||||
/\u001b\[[0-9;]*m/g,
|
||||
"",
|
||||
);
|
||||
|
||||
describe("formatHealthCheckFailure", () => {
|
||||
it("keeps non-rich output stable", () => {
|
||||
const err = new Error(
|
||||
"gateway closed (1006 abnormal closure): no close reason",
|
||||
);
|
||||
expect(formatHealthCheckFailure(err, { rich: false })).toBe(
|
||||
`Health check failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("formats gateway connection details as indented key/value lines", () => {
|
||||
const err = new Error(
|
||||
[
|
||||
"gateway closed (1006 abnormal closure (no close frame)): no close reason",
|
||||
"Gateway target: ws://127.0.0.1:19001",
|
||||
"Source: local loopback",
|
||||
"Config: /Users/steipete/.clawdbot-dev/clawdbot.json",
|
||||
"Bind: loopback",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(stripAnsi(formatHealthCheckFailure(err, { rich: true }))).toBe(
|
||||
[
|
||||
"Health check failed: gateway closed (1006 abnormal closure (no close frame)): no close reason",
|
||||
" Gateway target: ws://127.0.0.1:19001",
|
||||
" Source: local loopback",
|
||||
" Config: /Users/steipete/.clawdbot-dev/clawdbot.json",
|
||||
" Bind: loopback",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
|
||||
const formatKv = (line: string, rich: boolean) => {
|
||||
const idx = line.indexOf(": ");
|
||||
if (idx <= 0) return colorize(rich, theme.muted, line);
|
||||
const key = line.slice(0, idx);
|
||||
const value = line.slice(idx + 2);
|
||||
|
||||
const valueColor =
|
||||
key === "Gateway target" || key === "Config"
|
||||
? theme.command
|
||||
: key === "Source"
|
||||
? theme.muted
|
||||
: theme.info;
|
||||
|
||||
return `${colorize(rich, theme.muted, `${key}:`)} ${colorize(rich, valueColor, value)}`;
|
||||
};
|
||||
|
||||
export function formatHealthCheckFailure(
|
||||
err: unknown,
|
||||
opts: { rich?: boolean } = {},
|
||||
): string {
|
||||
const rich = opts.rich ?? isRich();
|
||||
const raw = String(err);
|
||||
const message = err instanceof Error ? err.message : raw;
|
||||
|
||||
if (!rich) return `Health check failed: ${raw}`;
|
||||
|
||||
const lines = message
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())
|
||||
.filter(Boolean);
|
||||
const detailsIdx = lines.findIndex((l) => l.startsWith("Gateway target: "));
|
||||
|
||||
const summaryLines = (detailsIdx >= 0 ? lines.slice(0, detailsIdx) : lines)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const detailLines = detailsIdx >= 0 ? lines.slice(detailsIdx) : [];
|
||||
|
||||
const summary = summaryLines.length > 0 ? summaryLines.join(" ") : message;
|
||||
const header = colorize(rich, theme.error.bold, "Health check failed");
|
||||
|
||||
const out: string[] = [`${header}: ${summary}`];
|
||||
for (const line of detailLines) {
|
||||
out.push(` ${formatKv(line, rich)}`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getConfigValueAtPath,
|
||||
parseConfigPath,
|
||||
setConfigValueAtPath,
|
||||
unsetConfigValueAtPath,
|
||||
} from "./config-paths.js";
|
||||
|
||||
describe("config paths", () => {
|
||||
it("rejects empty and blocked paths", () => {
|
||||
expect(parseConfigPath("")).toEqual({
|
||||
ok: false,
|
||||
error: "Invalid path. Use dot notation (e.g. foo.bar).",
|
||||
});
|
||||
expect(parseConfigPath("__proto__.polluted").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("sets, gets, and unsets nested values", () => {
|
||||
const root: Record<string, unknown> = {};
|
||||
const parsed = parseConfigPath("foo.bar");
|
||||
if (!parsed.ok || !parsed.path) throw new Error("path parse failed");
|
||||
setConfigValueAtPath(root, parsed.path, 123);
|
||||
expect(getConfigValueAtPath(root, parsed.path)).toBe(123);
|
||||
expect(unsetConfigValueAtPath(root, parsed.path)).toBe(true);
|
||||
expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
type PathNode = Record<string, unknown>;
|
||||
|
||||
const BLOCKED_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
export function parseConfigPath(raw: string): {
|
||||
ok: boolean;
|
||||
path?: string[];
|
||||
error?: string;
|
||||
} {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid path. Use dot notation (e.g. foo.bar).",
|
||||
};
|
||||
}
|
||||
const parts = trimmed.split(".").map((part) => part.trim());
|
||||
if (parts.some((part) => !part)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid path. Use dot notation (e.g. foo.bar).",
|
||||
};
|
||||
}
|
||||
if (parts.some((part) => BLOCKED_KEYS.has(part))) {
|
||||
return { ok: false, error: "Invalid path segment." };
|
||||
}
|
||||
return { ok: true, path: parts };
|
||||
}
|
||||
|
||||
export function setConfigValueAtPath(
|
||||
root: PathNode,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let cursor: PathNode = root;
|
||||
for (let idx = 0; idx < path.length - 1; idx += 1) {
|
||||
const key = path[idx];
|
||||
const next = cursor[key];
|
||||
if (!isPlainObject(next)) {
|
||||
cursor[key] = {};
|
||||
}
|
||||
cursor = cursor[key] as PathNode;
|
||||
}
|
||||
cursor[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
export function unsetConfigValueAtPath(
|
||||
root: PathNode,
|
||||
path: string[],
|
||||
): boolean {
|
||||
const stack: Array<{ node: PathNode; key: string }> = [];
|
||||
let cursor: PathNode = root;
|
||||
for (let idx = 0; idx < path.length - 1; idx += 1) {
|
||||
const key = path[idx];
|
||||
const next = cursor[key];
|
||||
if (!isPlainObject(next)) return false;
|
||||
stack.push({ node: cursor, key });
|
||||
cursor = next;
|
||||
}
|
||||
const leafKey = path[path.length - 1];
|
||||
if (!(leafKey in cursor)) return false;
|
||||
delete cursor[leafKey];
|
||||
for (let idx = stack.length - 1; idx >= 0; idx -= 1) {
|
||||
const { node, key } = stack[idx];
|
||||
const child = node[key];
|
||||
if (isPlainObject(child) && Object.keys(child).length === 0) {
|
||||
delete node[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getConfigValueAtPath(root: PathNode, path: string[]): unknown {
|
||||
let cursor: unknown = root;
|
||||
for (const key of path) {
|
||||
if (!isPlainObject(cursor)) return undefined;
|
||||
cursor = cursor[key];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,68 @@
|
||||
import {
|
||||
parseConfigPath,
|
||||
setConfigValueAtPath,
|
||||
unsetConfigValueAtPath,
|
||||
} from "./config-paths.js";
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
|
||||
type OverrideTree = Record<string, unknown>;
|
||||
|
||||
let overrides: OverrideTree = {};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
function parsePath(raw: string): string[] | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const parts = trimmed.split(".").map((part) => part.trim());
|
||||
if (parts.some((part) => !part)) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
function setOverrideAtPath(
|
||||
root: OverrideTree,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let cursor: OverrideTree = root;
|
||||
for (let idx = 0; idx < path.length - 1; idx += 1) {
|
||||
const key = path[idx];
|
||||
const next = cursor[key];
|
||||
if (!isPlainObject(next)) {
|
||||
cursor[key] = {};
|
||||
}
|
||||
cursor = cursor[key] as OverrideTree;
|
||||
}
|
||||
cursor[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
function unsetOverrideAtPath(root: OverrideTree, path: string[]): boolean {
|
||||
const stack: Array<{ node: OverrideTree; key: string }> = [];
|
||||
let cursor: OverrideTree = root;
|
||||
for (let idx = 0; idx < path.length - 1; idx += 1) {
|
||||
const key = path[idx];
|
||||
const next = cursor[key];
|
||||
if (!isPlainObject(next)) return false;
|
||||
stack.push({ node: cursor, key });
|
||||
cursor = next;
|
||||
}
|
||||
const leafKey = path[path.length - 1];
|
||||
if (!(leafKey in cursor)) return false;
|
||||
delete cursor[leafKey];
|
||||
for (let idx = stack.length - 1; idx >= 0; idx -= 1) {
|
||||
const { node, key } = stack[idx];
|
||||
const child = node[key];
|
||||
if (isPlainObject(child) && Object.keys(child).length === 0) {
|
||||
delete node[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeOverrides(base: unknown, override: unknown): unknown {
|
||||
if (!isPlainObject(base) || !isPlainObject(override)) return override;
|
||||
const next: OverrideTree = { ...base };
|
||||
@@ -19,15 +73,6 @@ function mergeOverrides(base: unknown, override: unknown): unknown {
|
||||
return next;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfigOverrides(): OverrideTree {
|
||||
return overrides;
|
||||
}
|
||||
@@ -43,11 +88,14 @@ export function setConfigOverride(
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const parsed = parseConfigPath(pathRaw);
|
||||
if (!parsed.ok || !parsed.path) {
|
||||
return { ok: false, error: parsed.error ?? "Invalid path." };
|
||||
const path = parsePath(pathRaw);
|
||||
if (!path) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid path. Use dot notation (e.g. foo.bar).",
|
||||
};
|
||||
}
|
||||
setConfigValueAtPath(overrides, parsed.path, value);
|
||||
setOverrideAtPath(overrides, path, value);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -56,15 +104,11 @@ export function unsetConfigOverride(pathRaw: string): {
|
||||
removed: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const parsed = parseConfigPath(pathRaw);
|
||||
if (!parsed.ok || !parsed.path) {
|
||||
return {
|
||||
ok: false,
|
||||
removed: false,
|
||||
error: parsed.error ?? "Invalid path.",
|
||||
};
|
||||
const path = parsePath(pathRaw);
|
||||
if (!path) {
|
||||
return { ok: false, removed: false, error: "Invalid path." };
|
||||
}
|
||||
const removed = unsetConfigValueAtPath(overrides, parsed.path);
|
||||
const removed = unsetOverrideAtPath(overrides, path);
|
||||
return { ok: true, removed };
|
||||
}
|
||||
|
||||
|
||||
@@ -827,13 +827,6 @@ export type SandboxBrowserSettings = {
|
||||
noVncPort?: number;
|
||||
headless?: boolean;
|
||||
enableNoVnc?: boolean;
|
||||
/**
|
||||
* When true (default), sandboxed browser control will try to start/reattach to
|
||||
* the sandbox browser container when a tool call needs it.
|
||||
*/
|
||||
autoStart?: boolean;
|
||||
/** Max time to wait for CDP to become reachable after auto-start (ms). */
|
||||
autoStartTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type SandboxPruneSettings = {
|
||||
@@ -1006,8 +999,6 @@ export type MessagesConfig = {
|
||||
ackReaction?: string;
|
||||
/** When to send ack reactions. Default: "group-mentions". */
|
||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
|
||||
/** Remove ack reaction after reply is sent (default: false). */
|
||||
removeAckAfterReply?: boolean;
|
||||
};
|
||||
|
||||
export type CommandsConfig = {
|
||||
|
||||
@@ -603,7 +603,6 @@ const MessagesSchema = z
|
||||
ackReactionScope: z
|
||||
.enum(["group-mentions", "group-all", "direct", "all"])
|
||||
.optional(),
|
||||
removeAckAfterReply: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
@@ -697,8 +696,6 @@ const SandboxBrowserSchema = z
|
||||
noVncPort: z.number().int().positive().optional(),
|
||||
headless: z.boolean().optional(),
|
||||
enableNoVnc: z.boolean().optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
autoStartTimeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
||||
@@ -49,10 +49,6 @@ import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { parseTelegramTarget } from "../telegram/targets.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { normalizeE164, truncateUtf16Safe } from "../utils.js";
|
||||
import {
|
||||
isWhatsAppGroupJid,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "../whatsapp/normalize.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
export type RunCronAgentTurnResult = {
|
||||
@@ -207,22 +203,15 @@ function resolveDeliveryTarget(
|
||||
|
||||
const sanitizedWhatsappTo = (() => {
|
||||
if (provider !== "whatsapp") return rawTo;
|
||||
if (rawTo && isWhatsAppGroupJid(rawTo)) {
|
||||
return normalizeWhatsAppTarget(rawTo) ?? rawTo;
|
||||
}
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) {
|
||||
return rawTo ? (normalizeWhatsAppTarget(rawTo) ?? rawTo) : rawTo;
|
||||
}
|
||||
if (rawAllow.includes("*")) return rawTo;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
.filter((val) => val.length > 1);
|
||||
if (allowFrom.length === 0) {
|
||||
return rawTo ? (normalizeWhatsAppTarget(rawTo) ?? rawTo) : rawTo;
|
||||
}
|
||||
if (allowFrom.length === 0) return rawTo;
|
||||
if (!rawTo) return allowFrom[0];
|
||||
const normalized = normalizeWhatsAppTarget(rawTo);
|
||||
if (normalized && allowFrom.includes(normalized)) return normalized;
|
||||
const normalized = normalizeE164(rawTo);
|
||||
if (allowFrom.includes(normalized)) return normalized;
|
||||
return allowFrom[0];
|
||||
})();
|
||||
|
||||
@@ -554,8 +543,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
summary: "Delivery skipped (no WhatsApp recipient).",
|
||||
};
|
||||
}
|
||||
const rawTo = resolvedDelivery.to;
|
||||
const to = normalizeWhatsAppTarget(rawTo) ?? rawTo;
|
||||
const to = normalizeE164(resolvedDelivery.to);
|
||||
try {
|
||||
await deliverPayloadsWithMedia({
|
||||
payloads,
|
||||
|
||||
+6
-16
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import {
|
||||
GATEWAY_LAUNCH_AGENT_LABEL,
|
||||
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
|
||||
@@ -12,11 +11,6 @@ import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const formatLine = (label: string, value: string) => {
|
||||
const rich = isRich();
|
||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
||||
};
|
||||
function resolveHomeDir(env: Record<string, string | undefined>): string {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
||||
if (!home) throw new Error("Missing HOME");
|
||||
@@ -384,9 +378,7 @@ export async function uninstallLegacyLaunchAgents({
|
||||
const dest = path.join(trashDir, `${agent.label}.plist`);
|
||||
try {
|
||||
await fs.rename(agent.plistPath, dest);
|
||||
stdout.write(
|
||||
`${formatLine("Moved legacy LaunchAgent to Trash", dest)}\n`,
|
||||
);
|
||||
stdout.write(`Moved legacy LaunchAgent to Trash: ${dest}\n`);
|
||||
} catch {
|
||||
stdout.write(
|
||||
`Legacy LaunchAgent remains at ${agent.plistPath} (could not move)\n`,
|
||||
@@ -422,7 +414,7 @@ export async function uninstallLaunchAgent({
|
||||
try {
|
||||
await fs.mkdir(trashDir, { recursive: true });
|
||||
await fs.rename(plistPath, dest);
|
||||
stdout.write(`${formatLine("Moved LaunchAgent to Trash", dest)}\n`);
|
||||
stdout.write(`Moved LaunchAgent to Trash: ${dest}\n`);
|
||||
} catch {
|
||||
stdout.write(`LaunchAgent remains at ${plistPath} (could not move)\n`);
|
||||
}
|
||||
@@ -454,7 +446,7 @@ export async function stopLaunchAgent({
|
||||
`launchctl bootout failed: ${res.stderr || res.stdout}`.trim(),
|
||||
);
|
||||
}
|
||||
stdout.write(`${formatLine("Stopped LaunchAgent", `${domain}/${label}`)}\n`);
|
||||
stdout.write(`Stopped LaunchAgent: ${domain}/${label}\n`);
|
||||
}
|
||||
|
||||
export async function installLaunchAgent({
|
||||
@@ -515,8 +507,8 @@ export async function installLaunchAgent({
|
||||
`${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`,
|
||||
]);
|
||||
|
||||
stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`);
|
||||
stdout.write(`${formatLine("Logs", stdoutPath)}\n`);
|
||||
stdout.write(`Installed LaunchAgent: ${plistPath}\n`);
|
||||
stdout.write(`Logs: ${stdoutPath}\n`);
|
||||
return { plistPath };
|
||||
}
|
||||
|
||||
@@ -533,7 +525,5 @@ export async function restartLaunchAgent({
|
||||
`launchctl kickstart failed: ${res.stderr || res.stdout}`.trim(),
|
||||
);
|
||||
}
|
||||
stdout.write(
|
||||
`${formatLine("Restarted LaunchAgent", `${domain}/${label}`)}\n`,
|
||||
);
|
||||
stdout.write(`Restarted LaunchAgent: ${domain}/${label}\n`);
|
||||
}
|
||||
+6
-20
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import {
|
||||
GATEWAY_WINDOWS_TASK_NAME,
|
||||
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
|
||||
@@ -13,11 +12,6 @@ import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const formatLine = (label: string, value: string) => {
|
||||
const rich = isRich();
|
||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
||||
};
|
||||
|
||||
function resolveHomeDir(env: Record<string, string | undefined>): string {
|
||||
const home = env.USERPROFILE?.trim() || env.HOME?.trim();
|
||||
if (!home) throw new Error("Missing HOME");
|
||||
@@ -235,10 +229,8 @@ export async function installScheduledTask({
|
||||
}
|
||||
|
||||
await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
|
||||
stdout.write(
|
||||
`${formatLine("Installed Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
|
||||
);
|
||||
stdout.write(`${formatLine("Task script", scriptPath)}\n`);
|
||||
stdout.write(`Installed Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
|
||||
stdout.write(`Task script: ${scriptPath}\n`);
|
||||
return { scriptPath };
|
||||
}
|
||||
|
||||
@@ -255,7 +247,7 @@ export async function uninstallScheduledTask({
|
||||
const scriptPath = resolveTaskScriptPath(env);
|
||||
try {
|
||||
await fs.unlink(scriptPath);
|
||||
stdout.write(`${formatLine("Removed task script", scriptPath)}\n`);
|
||||
stdout.write(`Removed task script: ${scriptPath}\n`);
|
||||
} catch {
|
||||
stdout.write(`Task script not found at ${scriptPath}\n`);
|
||||
}
|
||||
@@ -280,9 +272,7 @@ export async function stopScheduledTask({
|
||||
if (res.code !== 0 && !isTaskNotRunning(res)) {
|
||||
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
|
||||
}
|
||||
stdout.write(
|
||||
`${formatLine("Stopped Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
|
||||
);
|
||||
stdout.write(`Stopped Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
|
||||
}
|
||||
|
||||
export async function restartScheduledTask({
|
||||
@@ -296,9 +286,7 @@ export async function restartScheduledTask({
|
||||
if (res.code !== 0) {
|
||||
throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim());
|
||||
}
|
||||
stdout.write(
|
||||
`${formatLine("Restarted Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
|
||||
);
|
||||
stdout.write(`Restarted Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
|
||||
}
|
||||
|
||||
export async function isScheduledTaskInstalled(): Promise<boolean> {
|
||||
@@ -412,9 +400,7 @@ export async function uninstallLegacyScheduledTasks({
|
||||
|
||||
try {
|
||||
await fs.unlink(task.scriptPath);
|
||||
stdout.write(
|
||||
`${formatLine("Removed legacy task script", task.scriptPath)}\n`,
|
||||
);
|
||||
stdout.write(`Removed legacy task script: ${task.scriptPath}\n`);
|
||||
} catch {
|
||||
stdout.write(`Legacy task script not found at ${task.scriptPath}\n`);
|
||||
}
|
||||
|
||||
+5
-13
@@ -4,7 +4,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import {
|
||||
GATEWAY_SYSTEMD_SERVICE_NAME,
|
||||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
||||
@@ -14,11 +13,6 @@ import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const formatLine = (label: string, value: string) => {
|
||||
const rich = isRich();
|
||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
||||
};
|
||||
|
||||
function resolveHomeDir(env: Record<string, string | undefined>): string {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
||||
if (!home) throw new Error("Missing HOME");
|
||||
@@ -416,7 +410,7 @@ export async function installSystemdService({
|
||||
);
|
||||
}
|
||||
|
||||
stdout.write(`${formatLine("Installed systemd service", unitPath)}\n`);
|
||||
stdout.write(`Installed systemd service: ${unitPath}\n`);
|
||||
return { unitPath };
|
||||
}
|
||||
|
||||
@@ -434,7 +428,7 @@ export async function uninstallSystemdService({
|
||||
const unitPath = resolveSystemdUnitPath(env);
|
||||
try {
|
||||
await fs.unlink(unitPath);
|
||||
stdout.write(`${formatLine("Removed systemd service", unitPath)}\n`);
|
||||
stdout.write(`Removed systemd service: ${unitPath}\n`);
|
||||
} catch {
|
||||
stdout.write(`Systemd service not found at ${unitPath}\n`);
|
||||
}
|
||||
@@ -453,7 +447,7 @@ export async function stopSystemdService({
|
||||
`systemctl stop failed: ${res.stderr || res.stdout}`.trim(),
|
||||
);
|
||||
}
|
||||
stdout.write(`${formatLine("Stopped systemd service", unitName)}\n`);
|
||||
stdout.write(`Stopped systemd service: ${unitName}\n`);
|
||||
}
|
||||
|
||||
export async function restartSystemdService({
|
||||
@@ -469,7 +463,7 @@ export async function restartSystemdService({
|
||||
`systemctl restart failed: ${res.stderr || res.stdout}`.trim(),
|
||||
);
|
||||
}
|
||||
stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`);
|
||||
stdout.write(`Restarted systemd service: ${unitName}\n`);
|
||||
}
|
||||
|
||||
export async function isSystemdServiceEnabled(): Promise<boolean> {
|
||||
@@ -590,9 +584,7 @@ export async function uninstallLegacySystemdUnits({
|
||||
|
||||
try {
|
||||
await fs.unlink(unit.unitPath);
|
||||
stdout.write(
|
||||
`${formatLine("Removed legacy systemd service", unit.unitPath)}\n`,
|
||||
);
|
||||
stdout.write(`Removed legacy systemd service: ${unit.unitPath}\n`);
|
||||
} catch {
|
||||
stdout.write(`Legacy systemd unit not found at ${unit.unitPath}\n`);
|
||||
}
|
||||
|
||||
+23
-47
@@ -51,7 +51,7 @@ import { formatDurationSeconds } from "../infra/format-duration.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { fetchRemoteMedia } from "../media/fetch.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||
import {
|
||||
@@ -74,11 +74,7 @@ import {
|
||||
waitForDiscordGatewayStop,
|
||||
} from "./monitor.gateway.js";
|
||||
import { fetchDiscordApplicationId } from "./probe.js";
|
||||
import {
|
||||
reactMessageDiscord,
|
||||
removeReactionDiscord,
|
||||
sendMessageDiscord,
|
||||
} from "./send.js";
|
||||
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
@@ -962,7 +958,6 @@ export function createDiscordMessageHandler(params: {
|
||||
return;
|
||||
}
|
||||
const ackReaction = resolveAckReaction(cfg, route.agentId);
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
const shouldAckReaction = () => {
|
||||
if (!ackReaction) return false;
|
||||
if (ackReactionScope === "all") return true;
|
||||
@@ -977,19 +972,15 @@ export function createDiscordMessageHandler(params: {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const ackReactionPromise = shouldAckReaction()
|
||||
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
|
||||
rest: client.rest,
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
logVerbose(
|
||||
`discord react failed for channel ${message.channelId}: ${String(err)}`,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
if (shouldAckReaction()) {
|
||||
reactMessageDiscord(message.channelId, message.id, ackReaction, {
|
||||
rest: client.rest,
|
||||
}).catch((err) => {
|
||||
logVerbose(
|
||||
`discord react failed for channel ${message.channelId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const fromLabel = isDirectMessage
|
||||
? buildDirectLabel(author)
|
||||
@@ -1210,24 +1201,6 @@ export function createDiscordMessageHandler(params: {
|
||||
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
}
|
||||
if (removeAckAfterReply && ackReactionPromise && ackReaction) {
|
||||
const ackReactionValue = ackReaction;
|
||||
void ackReactionPromise.then((didAck) => {
|
||||
if (!didAck) return;
|
||||
removeReactionDiscord(
|
||||
message.channelId,
|
||||
message.id,
|
||||
ackReactionValue,
|
||||
{
|
||||
rest: client.rest,
|
||||
},
|
||||
).catch((err) => {
|
||||
logVerbose(
|
||||
`discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (
|
||||
isGuildMessage &&
|
||||
shouldClearHistory &&
|
||||
@@ -1879,16 +1852,19 @@ async function resolveMediaList(
|
||||
const out: DiscordMediaInfo[] = [];
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url: attachment.url,
|
||||
filePathHint: attachment.filename ?? attachment.url,
|
||||
const res = await fetch(attachment.url);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to download discord attachment: HTTP ${res.status}`,
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const mime = await detectMime({
|
||||
buffer,
|
||||
headerMime: attachment.content_type ?? res.headers.get("content-type"),
|
||||
filePath: attachment.filename ?? attachment.url,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? attachment.content_type,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
|
||||
import { getApiKeyForModel } from "../agents/model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "../agents/models-config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { GatewayClient } from "./client.js";
|
||||
import { startGatewayServer } from "./server.js";
|
||||
import { getFreePort } from "./test-helpers.js";
|
||||
|
||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||
const GATEWAY_LIVE = process.env.CLAWDBOT_LIVE_GATEWAY === "1";
|
||||
const ALL_MODELS =
|
||||
process.env.CLAWDBOT_LIVE_GATEWAY_ALL_MODELS === "1" ||
|
||||
process.env.CLAWDBOT_LIVE_GATEWAY_MODELS === "all";
|
||||
|
||||
const describeLive = LIVE && GATEWAY_LIVE ? describe : describe.skip;
|
||||
|
||||
function parseFilter(raw?: string): Set<string> | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed || trimmed === "all") return null;
|
||||
const ids = trimmed
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return ids.length ? new Set(ids) : null;
|
||||
}
|
||||
|
||||
function extractPayloadText(result: unknown): string {
|
||||
const record = result as Record<string, unknown>;
|
||||
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
|
||||
const texts = payloads
|
||||
.map((p) =>
|
||||
p && typeof p === "object"
|
||||
? (p as Record<string, unknown>).text
|
||||
: undefined,
|
||||
)
|
||||
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
|
||||
return texts.join("\n").trim();
|
||||
}
|
||||
|
||||
function isMeaningful(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.toLowerCase() === "ok") return false;
|
||||
if (trimmed.length < 60) return false;
|
||||
const words = trimmed.split(/\s+/g).filter(Boolean);
|
||||
if (words.length < 12) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
type AgentFinalPayload = {
|
||||
status?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
async function connectClient(params: { url: string; token: string }) {
|
||||
return await new Promise<GatewayClient>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const stop = (err?: Error, client?: GatewayClient) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(client as GatewayClient);
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
token: params.token,
|
||||
clientName: "vitest-live",
|
||||
clientVersion: "dev",
|
||||
mode: "test",
|
||||
onHelloOk: () => stop(undefined, client),
|
||||
onConnectError: (err) => stop(err),
|
||||
onClose: (code, reason) =>
|
||||
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
|
||||
});
|
||||
const timer = setTimeout(
|
||||
() => stop(new Error("gateway connect timeout")),
|
||||
10_000,
|
||||
);
|
||||
timer.unref();
|
||||
client.start();
|
||||
});
|
||||
}
|
||||
|
||||
describeLive("gateway live (dev agent, profile keys)", () => {
|
||||
it(
|
||||
"runs meaningful prompts across models with available keys",
|
||||
async () => {
|
||||
const previous = {
|
||||
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
||||
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS,
|
||||
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
||||
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
||||
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
||||
};
|
||||
|
||||
process.env.CLAWDBOT_SKIP_PROVIDERS = "1";
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||
|
||||
const token = `test-${randomUUID()}`;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
|
||||
const cfg = loadConfig();
|
||||
await ensureClawdbotModelsJson(cfg);
|
||||
|
||||
const workspaceDir = resolveUserPath(
|
||||
cfg.agents?.defaults?.workspace ?? path.join(os.homedir(), "clawd"),
|
||||
);
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
const nonceA = randomUUID();
|
||||
const nonceB = randomUUID();
|
||||
const toolProbePath = path.join(
|
||||
workspaceDir,
|
||||
`.clawdbot-live-tool-probe.${nonceA}.txt`,
|
||||
);
|
||||
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
|
||||
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, agentDir);
|
||||
const all = modelRegistry.getAll() as Array<Model<Api>>;
|
||||
|
||||
const filter = parseFilter(process.env.CLAWDBOT_LIVE_GATEWAY_MODELS);
|
||||
|
||||
// Default: honor user allowlist. Opt-in: scan all models with keys.
|
||||
const allowlistKeys = Object.keys(cfg.agents?.defaults?.models ?? {});
|
||||
const wanted =
|
||||
ALL_MODELS || allowlistKeys.length === 0
|
||||
? all
|
||||
: all.filter((m) => allowlistKeys.includes(`${m.provider}/${m.id}`));
|
||||
|
||||
const candidates: Array<Model<Api>> = [];
|
||||
for (const model of wanted) {
|
||||
const id = `${model.provider}/${model.id}`;
|
||||
if (filter && !filter.has(id)) continue;
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await getApiKeyForModel({ model, cfg });
|
||||
candidates.push(model);
|
||||
} catch {
|
||||
// no creds; skip
|
||||
}
|
||||
}
|
||||
|
||||
expect(candidates.length).toBeGreaterThan(0);
|
||||
|
||||
// Build a temp config that allows all selected models, so session overrides stick.
|
||||
const nextCfg = {
|
||||
...cfg,
|
||||
agents: {
|
||||
...(cfg.agents ?? {}),
|
||||
defaults: {
|
||||
...(cfg.agents?.defaults ?? {}),
|
||||
models: Object.fromEntries(
|
||||
candidates.map((m) => [`${m.provider}/${m.id}`, {}]),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
const tempDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-live-"),
|
||||
);
|
||||
const tempConfigPath = path.join(tempDir, "clawdbot.json");
|
||||
await fs.writeFile(
|
||||
tempConfigPath,
|
||||
`${JSON.stringify(nextCfg, null, 2)}\n`,
|
||||
);
|
||||
process.env.CLAWDBOT_CONFIG_PATH = tempConfigPath;
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
const client = await connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
});
|
||||
|
||||
try {
|
||||
const sessionKey = "agent:dev:live-gateway";
|
||||
|
||||
const failures: Array<{ model: string; error: string }> = [];
|
||||
|
||||
for (const model of candidates) {
|
||||
const modelKey = `${model.provider}/${model.id}`;
|
||||
|
||||
try {
|
||||
// Ensure session exists + override model for this run.
|
||||
await client.request<Record<string, unknown>>("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: modelKey,
|
||||
});
|
||||
|
||||
// “Meaningful” direct prompt (no tools).
|
||||
const runId = randomUUID();
|
||||
const payload = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId}`,
|
||||
message:
|
||||
"Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.",
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
|
||||
if (payload?.status !== "ok") {
|
||||
throw new Error(`agent status=${String(payload?.status)}`);
|
||||
}
|
||||
const text = extractPayloadText(payload?.result);
|
||||
if (!isMeaningful(text)) throw new Error(`not meaningful: ${text}`);
|
||||
if (
|
||||
!/\\bmicrotask\\b/i.test(text) ||
|
||||
!/\\bmacrotask\\b/i.test(text)
|
||||
) {
|
||||
throw new Error(`missing required keywords: ${text}`);
|
||||
}
|
||||
|
||||
// Real tool invocation: force the agent to Read a local file and echo a nonce.
|
||||
const runIdTool = randomUUID();
|
||||
const toolProbe = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runIdTool}-tool`,
|
||||
message:
|
||||
`Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) on "${toolProbePath}". ` +
|
||||
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
if (toolProbe?.status !== "ok") {
|
||||
throw new Error(
|
||||
`tool probe failed: status=${String(toolProbe?.status)}`,
|
||||
);
|
||||
}
|
||||
const toolText = extractPayloadText(toolProbe?.result);
|
||||
if (!toolText.includes(nonceA) || !toolText.includes(nonceB)) {
|
||||
throw new Error(`tool probe missing nonce: ${toolText}`);
|
||||
}
|
||||
|
||||
// Regression: tool-call-only turn followed by a user message (OpenAI responses bug class).
|
||||
if (
|
||||
(model.provider === "openai" &&
|
||||
model.api === "openai-responses") ||
|
||||
(model.provider === "openai-codex" &&
|
||||
model.api === "openai-codex-responses")
|
||||
) {
|
||||
const runId2 = randomUUID();
|
||||
const first = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId2}-1`,
|
||||
message:
|
||||
"Call the tool named `read` (or `Read`) on package.json. Do not write any other text.",
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
if (first?.status !== "ok") {
|
||||
throw new Error(
|
||||
`tool-only turn failed: status=${String(first?.status)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const second = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId2}-2`,
|
||||
message:
|
||||
'Now answer: what is the "version" field in package.json? Reply with just the version string.',
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
if (second?.status !== "ok") {
|
||||
throw new Error(
|
||||
`post-tool message failed: status=${String(second?.status)}`,
|
||||
);
|
||||
}
|
||||
const version = extractPayloadText(second?.result);
|
||||
if (!/^\\d{4}\\.\\d+\\.\\d+/.test(version.trim())) {
|
||||
throw new Error(`unexpected version: ${version}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push({ model: modelKey, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const preview = failures
|
||||
.slice(0, 20)
|
||||
.map((f) => `- ${f.model}: ${f.error}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`gateway live model failures (${failures.length}):\n${preview}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
client.stop();
|
||||
await server.close({ reason: "live test complete" });
|
||||
await fs.rm(toolProbePath, { force: true });
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
process.env.CLAWDBOT_CONFIG_PATH = previous.configPath;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token;
|
||||
process.env.CLAWDBOT_SKIP_PROVIDERS = previous.skipProviders;
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail;
|
||||
process.env.CLAWDBOT_SKIP_CRON = previous.skipCron;
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas;
|
||||
}
|
||||
},
|
||||
20 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
@@ -1,426 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type OpenAIResponsesParams = {
|
||||
input?: unknown[];
|
||||
};
|
||||
|
||||
type OpenAIResponseStreamEvent =
|
||||
| { type: "response.output_item.added"; item: Record<string, unknown> }
|
||||
| { type: "response.function_call_arguments.delta"; delta: string }
|
||||
| { type: "response.output_item.done"; item: Record<string, unknown> }
|
||||
| {
|
||||
type: "response.completed";
|
||||
response: {
|
||||
status: "completed";
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
input_tokens_details?: { cached_tokens?: number };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function extractLastUserText(input: unknown[]): string {
|
||||
for (let i = input.length - 1; i >= 0; i -= 1) {
|
||||
const item = input[i] as Record<string, unknown> | undefined;
|
||||
if (!item || item.role !== "user") continue;
|
||||
const content = item.content;
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter(
|
||||
(c): c is { type: "input_text"; text: string } =>
|
||||
!!c &&
|
||||
typeof c === "object" &&
|
||||
(c as { type?: unknown }).type === "input_text" &&
|
||||
typeof (c as { text?: unknown }).text === "string",
|
||||
)
|
||||
.map((c) => c.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractToolOutput(input: unknown[]): string {
|
||||
for (const itemRaw of input) {
|
||||
const item = itemRaw as Record<string, unknown> | undefined;
|
||||
if (!item || item.type !== "function_call_output") continue;
|
||||
return typeof item.output === "string" ? item.output : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function* fakeOpenAIResponsesStream(
|
||||
params: OpenAIResponsesParams,
|
||||
): AsyncGenerator<OpenAIResponseStreamEvent> {
|
||||
const input = Array.isArray(params.input) ? params.input : [];
|
||||
const toolOutput = extractToolOutput(input);
|
||||
|
||||
// Turn 1: return a tool call to `read`.
|
||||
if (!toolOutput) {
|
||||
const prompt = extractLastUserText(input);
|
||||
const quoted = /"([^"]+)"/.exec(prompt)?.[1];
|
||||
const toolPath = quoted ?? "package.json";
|
||||
const argsJson = JSON.stringify({ path: toolPath });
|
||||
|
||||
yield {
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_test_1",
|
||||
call_id: "call_test_1",
|
||||
name: "read",
|
||||
arguments: "",
|
||||
},
|
||||
};
|
||||
yield { type: "response.function_call_arguments.delta", delta: argsJson };
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_test_1",
|
||||
call_id: "call_test_1",
|
||||
name: "read",
|
||||
arguments: argsJson,
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
status: "completed",
|
||||
usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Turn 2: echo the nonces extracted from the Read tool output.
|
||||
const nonceA = /nonceA=([^\s]+)/.exec(toolOutput)?.[1] ?? "";
|
||||
const nonceB = /nonceB=([^\s]+)/.exec(toolOutput)?.[1] ?? "";
|
||||
const reply = `${nonceA} ${nonceB}`.trim();
|
||||
|
||||
yield {
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_test_1",
|
||||
role: "assistant",
|
||||
content: [],
|
||||
status: "in_progress",
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_test_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: reply, annotations: [] }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
status: "completed",
|
||||
usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBodyText(body: unknown): string {
|
||||
if (!body) return "";
|
||||
if (typeof body === "string") return body;
|
||||
if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
|
||||
if (body instanceof ArrayBuffer)
|
||||
return Buffer.from(new Uint8Array(body)).toString("utf8");
|
||||
return "";
|
||||
}
|
||||
|
||||
async function buildOpenAIResponsesSse(
|
||||
params: OpenAIResponsesParams,
|
||||
): Promise<Response> {
|
||||
const events: OpenAIResponseStreamEvent[] = [];
|
||||
for await (const event of fakeOpenAIResponsesStream(params)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const sse = `${events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")}data: [DONE]\n\n`;
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(sse));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = createServer();
|
||||
srv.on("error", reject);
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
srv.close();
|
||||
reject(new Error("failed to acquire free port"));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
|
||||
return await new Promise((resolve) => {
|
||||
const srv = createServer();
|
||||
srv.once("error", () => resolve(false));
|
||||
srv.listen(port, "127.0.0.1", () => {
|
||||
srv.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getFreeGatewayPort(): Promise<number> {
|
||||
// Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by
|
||||
// ensuring the common derived offsets are free too.
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
const port = await getFreePort();
|
||||
const candidates = [port, port + 1, port + 2, port + 4];
|
||||
const ok = (
|
||||
await Promise.all(candidates.map((candidate) => isPortFree(candidate)))
|
||||
).every(Boolean);
|
||||
if (ok) return port;
|
||||
}
|
||||
throw new Error("failed to acquire a free gateway port block");
|
||||
}
|
||||
|
||||
function extractPayloadText(result: unknown): string {
|
||||
const record = result as Record<string, unknown>;
|
||||
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
|
||||
const texts = payloads
|
||||
.map((p) =>
|
||||
p && typeof p === "object"
|
||||
? (p as Record<string, unknown>).text
|
||||
: undefined,
|
||||
)
|
||||
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
|
||||
return texts.join("\n").trim();
|
||||
}
|
||||
|
||||
async function connectClient(params: { url: string; token: string }) {
|
||||
const { GatewayClient } = await import("./client.js");
|
||||
return await new Promise<InstanceType<typeof GatewayClient>>(
|
||||
(resolve, reject) => {
|
||||
let settled = false;
|
||||
const stop = (
|
||||
err?: Error,
|
||||
client?: InstanceType<typeof GatewayClient>,
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(client as InstanceType<typeof GatewayClient>);
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
token: params.token,
|
||||
clientName: "vitest-mock-openai",
|
||||
clientVersion: "dev",
|
||||
mode: "test",
|
||||
onHelloOk: () => stop(undefined, client),
|
||||
onConnectError: (err) => stop(err),
|
||||
onClose: (code, reason) =>
|
||||
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
|
||||
});
|
||||
const timer = setTimeout(
|
||||
() => stop(new Error("gateway connect timeout")),
|
||||
10_000,
|
||||
);
|
||||
timer.unref();
|
||||
client.start();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("gateway (mock openai): tool calling", () => {
|
||||
it("runs a Read tool call end-to-end via gateway agent loop", async () => {
|
||||
const prev = {
|
||||
home: process.env.HOME,
|
||||
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
||||
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS,
|
||||
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
||||
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
||||
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
||||
};
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const openaiResponsesUrl = "https://api.openai.com/v1/responses";
|
||||
const fetchImpl = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
|
||||
if (url === openaiResponsesUrl) {
|
||||
const bodyText =
|
||||
typeof (init as { body?: unknown } | undefined)?.body !== "undefined"
|
||||
? decodeBodyText((init as { body?: unknown }).body)
|
||||
: input instanceof Request
|
||||
? await input.clone().text()
|
||||
: "";
|
||||
|
||||
const parsed = bodyText
|
||||
? (JSON.parse(bodyText) as Record<string, unknown>)
|
||||
: {};
|
||||
const inputItems = Array.isArray(parsed.input) ? parsed.input : [];
|
||||
return await buildOpenAIResponsesSse({ input: inputItems });
|
||||
}
|
||||
|
||||
if (!originalFetch) {
|
||||
throw new Error(`fetch is not available (url=${url})`);
|
||||
}
|
||||
return await originalFetch(input, init);
|
||||
};
|
||||
// TypeScript: Bun's fetch typing includes extra properties; keep this test portable.
|
||||
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
|
||||
|
||||
const tempHome = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-gw-mock-home-"),
|
||||
);
|
||||
process.env.HOME = tempHome;
|
||||
process.env.CLAWDBOT_SKIP_PROVIDERS = "1";
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||
|
||||
const token = `test-${randomUUID()}`;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
|
||||
const workspaceDir = path.join(tempHome, "clawd");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
const nonceA = randomUUID();
|
||||
const nonceB = randomUUID();
|
||||
const toolProbePath = path.join(
|
||||
workspaceDir,
|
||||
`.clawdbot-tool-probe.${nonceA}.txt`,
|
||||
);
|
||||
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
|
||||
|
||||
const configDir = path.join(tempHome, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "clawdbot.json");
|
||||
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: workspaceDir } },
|
||||
models: {
|
||||
mode: "replace",
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "test",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
gateway: { auth: { token } },
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
process.env.CLAWDBOT_CONFIG_PATH = configPath;
|
||||
|
||||
const port = await getFreeGatewayPort();
|
||||
const { startGatewayServer } = await import("./server.js");
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
const client = await connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
});
|
||||
|
||||
try {
|
||||
const sessionKey = "agent:dev:mock-openai";
|
||||
|
||||
await client.request<Record<string, unknown>>("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: "openai/gpt-5.2",
|
||||
});
|
||||
|
||||
const runId = randomUUID();
|
||||
const payload = await client.request<{
|
||||
status?: unknown;
|
||||
result?: unknown;
|
||||
}>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId}`,
|
||||
message:
|
||||
`Call the read tool on "${toolProbePath}". ` +
|
||||
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
|
||||
expect(payload?.status).toBe("ok");
|
||||
const text = extractPayloadText(payload?.result);
|
||||
expect(text).toContain(nonceA);
|
||||
expect(text).toContain(nonceB);
|
||||
} finally {
|
||||
client.stop();
|
||||
await server.close({ reason: "mock openai test complete" });
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
|
||||
process.env.HOME = prev.home;
|
||||
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
||||
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders;
|
||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
||||
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -19,10 +19,6 @@ import {
|
||||
normalizeMessageProvider,
|
||||
} from "../../utils/message-provider.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import {
|
||||
isWhatsAppGroupJid,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "../../whatsapp/normalize.js";
|
||||
import {
|
||||
type AgentWaitParams,
|
||||
ErrorCodes,
|
||||
@@ -225,21 +221,11 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
typeof request.to === "string" && request.to.trim()
|
||||
? request.to.trim()
|
||||
: undefined;
|
||||
if (explicit) {
|
||||
if (!resolvedTo) return resolvedTo;
|
||||
return normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo;
|
||||
}
|
||||
if (resolvedTo && isWhatsAppGroupJid(resolvedTo)) {
|
||||
return normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo;
|
||||
}
|
||||
if (explicit) return resolvedTo;
|
||||
|
||||
const cfg = cfgForAgent ?? loadConfig();
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) {
|
||||
return resolvedTo
|
||||
? (normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo)
|
||||
: resolvedTo;
|
||||
}
|
||||
if (rawAllow.includes("*")) return resolvedTo;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
.filter((val) => val.length > 1);
|
||||
@@ -247,7 +233,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
|
||||
const normalizedLast =
|
||||
typeof resolvedTo === "string" && resolvedTo.trim()
|
||||
? normalizeWhatsAppTarget(resolvedTo)
|
||||
? normalizeE164(resolvedTo)
|
||||
: undefined;
|
||||
if (normalizedLast && allowFrom.includes(normalizedLast)) {
|
||||
return normalizedLast;
|
||||
|
||||
@@ -96,21 +96,6 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes explicit WhatsApp targets when allowFrom is '*'", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" },
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
};
|
||||
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
|
||||
provider: "whatsapp",
|
||||
to: "+555123",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips when last route is webchat", () => {
|
||||
const cfg: ClawdbotConfig = {};
|
||||
const entry = {
|
||||
@@ -141,36 +126,6 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps WhatsApp group targets even with allowFrom set", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { allowFrom: ["+1555"] },
|
||||
};
|
||||
const entry = {
|
||||
...baseEntry,
|
||||
lastProvider: "whatsapp" as const,
|
||||
lastTo: "120363401234567890@g.us",
|
||||
};
|
||||
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
||||
provider: "whatsapp",
|
||||
to: "120363401234567890@g.us",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes prefixed WhatsApp group targets for heartbeat delivery", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
whatsapp: { allowFrom: ["+1555"] },
|
||||
};
|
||||
const entry = {
|
||||
...baseEntry,
|
||||
lastProvider: "whatsapp" as const,
|
||||
lastTo: "whatsapp:group:120363401234567890@G.US",
|
||||
};
|
||||
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
||||
provider: "whatsapp",
|
||||
to: "120363401234567890@g.us",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit telegram targets", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
|
||||
|
||||
@@ -3,64 +3,22 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveOutboundTarget } from "./targets.js";
|
||||
|
||||
describe("resolveOutboundTarget", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "normalizes whatsapp target when provided",
|
||||
input: { provider: "whatsapp" as const, to: " (555) 123-4567 " },
|
||||
expected: { ok: true as const, to: "+5551234567" },
|
||||
},
|
||||
{
|
||||
name: "keeps whatsapp group targets",
|
||||
input: { provider: "whatsapp" as const, to: "120363401234567890@g.us" },
|
||||
expected: { ok: true as const, to: "120363401234567890@g.us" },
|
||||
},
|
||||
{
|
||||
name: "normalizes prefixed/uppercase whatsapp group targets",
|
||||
input: {
|
||||
provider: "whatsapp" as const,
|
||||
to: " WhatsApp:Group:120363401234567890@G.US ",
|
||||
},
|
||||
expected: { ok: true as const, to: "120363401234567890@g.us" },
|
||||
},
|
||||
{
|
||||
name: "falls back to whatsapp allowFrom",
|
||||
input: { provider: "whatsapp" as const, to: "", allowFrom: ["+1555"] },
|
||||
expected: { ok: true as const, to: "+1555" },
|
||||
},
|
||||
{
|
||||
name: "normalizes whatsapp allowFrom fallback targets",
|
||||
input: {
|
||||
provider: "whatsapp" as const,
|
||||
to: "",
|
||||
allowFrom: ["whatsapp:(555) 123-4567"],
|
||||
},
|
||||
expected: { ok: true as const, to: "+5551234567" },
|
||||
},
|
||||
{
|
||||
name: "rejects invalid whatsapp target",
|
||||
input: { provider: "whatsapp" as const, to: "wat" },
|
||||
expectedErrorIncludes: "WhatsApp",
|
||||
},
|
||||
{
|
||||
name: "rejects whatsapp without to when allowFrom missing",
|
||||
input: { provider: "whatsapp" as const, to: " " },
|
||||
expectedErrorIncludes: "WhatsApp",
|
||||
},
|
||||
{
|
||||
name: "rejects whatsapp allowFrom fallback when invalid",
|
||||
input: { provider: "whatsapp" as const, to: "", allowFrom: ["wat"] },
|
||||
expectedErrorIncludes: "WhatsApp",
|
||||
},
|
||||
])("$name", ({ input, expected, expectedErrorIncludes }) => {
|
||||
const res = resolveOutboundTarget(input);
|
||||
if (expected) {
|
||||
expect(res).toEqual(expected);
|
||||
return;
|
||||
}
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.message).toContain(expectedErrorIncludes);
|
||||
}
|
||||
it("falls back to whatsapp allowFrom", () => {
|
||||
const res = resolveOutboundTarget({
|
||||
provider: "whatsapp",
|
||||
to: "",
|
||||
allowFrom: ["+1555"],
|
||||
});
|
||||
expect(res).toEqual({ ok: true, to: "+1555" });
|
||||
});
|
||||
|
||||
it("normalizes whatsapp target when provided", () => {
|
||||
const res = resolveOutboundTarget({
|
||||
provider: "whatsapp",
|
||||
to: " (555) 123-4567 ",
|
||||
});
|
||||
if (!res.ok) throw res.error;
|
||||
expect(res.to).toBe("+5551234567");
|
||||
});
|
||||
|
||||
it("rejects telegram with missing target", () => {
|
||||
|
||||
@@ -5,10 +5,6 @@ import type {
|
||||
GatewayMessageProvider,
|
||||
} from "../../utils/message-provider.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import {
|
||||
isWhatsAppGroupJid,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "../../whatsapp/normalize.js";
|
||||
|
||||
export type OutboundProvider = DeliverableMessageProvider | "none";
|
||||
|
||||
@@ -24,7 +20,7 @@ export type OutboundTargetResolution =
|
||||
| { ok: true; to: string }
|
||||
| { ok: false; error: Error };
|
||||
|
||||
export function normalizeOutboundTarget(params: {
|
||||
export function resolveOutboundTarget(params: {
|
||||
provider: GatewayMessageProvider;
|
||||
to?: string;
|
||||
allowFrom?: string[];
|
||||
@@ -32,28 +28,16 @@ export function normalizeOutboundTarget(params: {
|
||||
const trimmed = params.to?.trim() || "";
|
||||
if (params.provider === "whatsapp") {
|
||||
if (trimmed) {
|
||||
const normalized = normalizeWhatsAppTarget(trimmed);
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Delivering to WhatsApp requires --to <E.164|group JID> or whatsapp.allowFrom[0]",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: normalized };
|
||||
return { ok: true, to: normalizeE164(trimmed) };
|
||||
}
|
||||
const fallback = params.allowFrom?.[0]?.trim();
|
||||
if (fallback) {
|
||||
const normalized = normalizeWhatsAppTarget(fallback);
|
||||
if (normalized) {
|
||||
return { ok: true, to: normalized };
|
||||
}
|
||||
return { ok: true, to: fallback };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Delivering to WhatsApp requires --to <E.164|group JID> or whatsapp.allowFrom[0]",
|
||||
"Delivering to WhatsApp requires --to <E.164> or whatsapp.allowFrom[0]",
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -129,14 +113,6 @@ export function normalizeOutboundTarget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOutboundTarget(params: {
|
||||
provider: GatewayMessageProvider;
|
||||
to?: string;
|
||||
allowFrom?: string[];
|
||||
}): OutboundTargetResolution {
|
||||
return normalizeOutboundTarget(params);
|
||||
}
|
||||
|
||||
export function resolveHeartbeatDeliveryTarget(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
entry?: SessionEntry;
|
||||
@@ -202,14 +178,14 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
}
|
||||
|
||||
if (provider !== "whatsapp") {
|
||||
const resolved = normalizeOutboundTarget({ provider, to });
|
||||
const resolved = resolveOutboundTarget({ provider, to });
|
||||
return resolved.ok
|
||||
? { provider, to: resolved.to }
|
||||
: { provider: "none", reason: "no-target" };
|
||||
}
|
||||
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
const resolved = normalizeOutboundTarget({
|
||||
const resolved = resolveOutboundTarget({
|
||||
provider: "whatsapp",
|
||||
to,
|
||||
allowFrom: rawAllow,
|
||||
@@ -218,7 +194,6 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
return { provider: "none", reason: "no-target" };
|
||||
}
|
||||
if (rawAllow.includes("*")) return { provider, to: resolved.to };
|
||||
if (isWhatsAppGroupJid(resolved.to)) return { provider, to: resolved.to };
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
.filter((val) => val.length > 1);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { parseInlineDirectives } from "../utils/directive-tags.js";
|
||||
|
||||
/**
|
||||
* Extract audio mode tag from text.
|
||||
* Supports [[audio_as_voice]] to send audio as voice bubble instead of file.
|
||||
* Default is file (preserves backward compatibility).
|
||||
*/
|
||||
export function parseAudioTag(text?: string): {
|
||||
text: string;
|
||||
audioAsVoice: boolean;
|
||||
hadTag: boolean;
|
||||
} {
|
||||
const result = parseInlineDirectives(text, { stripReplyTags: false });
|
||||
return {
|
||||
text: result.text,
|
||||
audioAsVoice: result.audioAsVoice,
|
||||
hadTag: result.hasAudioTag,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { getFileExtension } from "./mime.js";
|
||||
|
||||
const VOICE_AUDIO_EXTENSIONS = new Set([".oga", ".ogg", ".opus"]);
|
||||
|
||||
export function isVoiceCompatibleAudio(opts: {
|
||||
contentType?: string | null;
|
||||
fileName?: string | null;
|
||||
}): boolean {
|
||||
const mime = opts.contentType?.toLowerCase();
|
||||
if (mime && (mime.includes("ogg") || mime.includes("opus"))) {
|
||||
return true;
|
||||
}
|
||||
const fileName = opts.fileName?.trim();
|
||||
if (!fileName) return false;
|
||||
const ext = getFileExtension(fileName);
|
||||
if (!ext) return false;
|
||||
return VOICE_AUDIO_EXTENSIONS.has(ext);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { detectMime, extensionForMime } from "./mime.js";
|
||||
|
||||
type FetchMediaResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export type FetchLike = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
type FetchMediaOptions = {
|
||||
url: string;
|
||||
fetchImpl?: FetchLike;
|
||||
filePathHint?: string;
|
||||
};
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
return value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
function parseContentDispositionFileName(
|
||||
header?: string | null,
|
||||
): string | undefined {
|
||||
if (!header) return undefined;
|
||||
const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header);
|
||||
if (starMatch?.[1]) {
|
||||
const cleaned = stripQuotes(starMatch[1].trim());
|
||||
const encoded = cleaned.split("''").slice(1).join("''") || cleaned;
|
||||
try {
|
||||
return path.basename(decodeURIComponent(encoded));
|
||||
} catch {
|
||||
return path.basename(encoded);
|
||||
}
|
||||
}
|
||||
const match = /filename\s*=\s*([^;]+)/i.exec(header);
|
||||
if (match?.[1]) return path.basename(stripQuotes(match[1].trim()));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readErrorBodySnippet(
|
||||
res: Response,
|
||||
maxChars = 200,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (!text) return undefined;
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
if (!collapsed) return undefined;
|
||||
if (collapsed.length <= maxChars) return collapsed;
|
||||
return `${collapsed.slice(0, maxChars)}…`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRemoteMedia(
|
||||
options: FetchMediaOptions,
|
||||
): Promise<FetchMediaResult> {
|
||||
const { url, fetchImpl, filePathHint } = options;
|
||||
const fetcher: FetchLike | undefined = fetchImpl ?? globalThis.fetch;
|
||||
if (!fetcher) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetcher(url);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch media from ${url}: ${String(err)}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const statusText = res.statusText ? ` ${res.statusText}` : "";
|
||||
const redirected =
|
||||
res.url && res.url !== url ? ` (redirected to ${res.url})` : "";
|
||||
let detail = `HTTP ${res.status}${statusText}`;
|
||||
if (!res.body) {
|
||||
detail = `HTTP ${res.status}${statusText}; empty response body`;
|
||||
} else {
|
||||
const snippet = await readErrorBodySnippet(res);
|
||||
if (snippet) detail += `; body: ${snippet}`;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to fetch media from ${url}${redirected}: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
let fileNameFromUrl: string | undefined;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const base = path.basename(parsed.pathname);
|
||||
fileNameFromUrl = base || undefined;
|
||||
} catch {
|
||||
// ignore parse errors; leave undefined
|
||||
}
|
||||
|
||||
const headerFileName = parseContentDispositionFileName(
|
||||
res.headers.get("content-disposition"),
|
||||
);
|
||||
let fileName =
|
||||
headerFileName ||
|
||||
fileNameFromUrl ||
|
||||
(filePathHint ? path.basename(filePathHint) : undefined);
|
||||
|
||||
const filePathForMime =
|
||||
headerFileName && path.extname(headerFileName)
|
||||
? headerFileName
|
||||
: (filePathHint ?? url);
|
||||
const contentType = await detectMime({
|
||||
buffer,
|
||||
headerMime: res.headers.get("content-type"),
|
||||
filePath: filePathForMime,
|
||||
});
|
||||
if (fileName && !path.extname(fileName) && contentType) {
|
||||
const ext = extensionForMime(contentType);
|
||||
if (ext) fileName = `${fileName}${ext}`;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType: contentType ?? undefined,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
+5
-20
@@ -36,17 +36,6 @@ const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
|
||||
);
|
||||
|
||||
const AUDIO_FILE_EXTENSIONS = new Set([
|
||||
".aac",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
]);
|
||||
|
||||
function normalizeHeaderMime(mime?: string | null): string | undefined {
|
||||
if (!mime) return undefined;
|
||||
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
|
||||
@@ -63,7 +52,7 @@ async function sniffMime(buffer?: Buffer): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExtension(filePath?: string | null): string | undefined {
|
||||
function extFromPath(filePath?: string): string | undefined {
|
||||
if (!filePath) return undefined;
|
||||
try {
|
||||
if (/^https?:\/\//i.test(filePath)) {
|
||||
@@ -77,12 +66,6 @@ export function getFileExtension(filePath?: string | null): string | undefined {
|
||||
return ext || undefined;
|
||||
}
|
||||
|
||||
export function isAudioFileName(fileName?: string | null): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
if (!ext) return false;
|
||||
return AUDIO_FILE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export function detectMime(opts: {
|
||||
buffer?: Buffer;
|
||||
headerMime?: string | null;
|
||||
@@ -102,7 +85,7 @@ async function detectMimeImpl(opts: {
|
||||
headerMime?: string | null;
|
||||
filePath?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const ext = getFileExtension(opts.filePath);
|
||||
const ext = extFromPath(opts.filePath);
|
||||
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
|
||||
|
||||
const headerMime = normalizeHeaderMime(opts.headerMime);
|
||||
@@ -129,7 +112,9 @@ export function isGifMedia(opts: {
|
||||
fileName?: string | null;
|
||||
}): boolean {
|
||||
if (opts.contentType?.toLowerCase() === "image/gif") return true;
|
||||
const ext = getFileExtension(opts.fileName);
|
||||
const ext = opts.fileName
|
||||
? path.extname(opts.fileName).toLowerCase()
|
||||
: undefined;
|
||||
return ext === ".gif";
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user