Compare commits
154 Commits
packager-r
...
build-runt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3254bae4ca | ||
|
|
77d162fc7f | ||
|
|
dc9808a674 | ||
|
|
60ad2c2e96 | ||
|
|
a7cbce1b3d | ||
|
|
a74251d415 | ||
|
|
cbc3de6c97 | ||
|
|
01b1e350b2 | ||
|
|
3b4096e02e | ||
|
|
adc818db4a | ||
|
|
568fd337be | ||
|
|
d9ca051a1d | ||
|
|
1b6704ef53 | ||
|
|
887b209db4 | ||
|
|
d4bdcda324 | ||
|
|
966957fc66 | ||
|
|
555eb3f62c | ||
|
|
93b9f1ec5f | ||
|
|
6254e96acf | ||
|
|
19f53543d2 | ||
|
|
618008b483 | ||
|
|
31d1ed351f | ||
|
|
22c1210a16 | ||
|
|
273d70741f | ||
|
|
07be14c02d | ||
|
|
5b2cb8ba11 | ||
|
|
1d7b2bc9c8 | ||
|
|
a881bd41eb | ||
|
|
cd44a0d01e | ||
|
|
a73e7786e7 | ||
|
|
2679089e9e | ||
|
|
c95a61aa9d | ||
|
|
73a97ee255 | ||
|
|
b1dca644bc | ||
|
|
b743e652c0 | ||
|
|
71cee673b2 | ||
|
|
dc5d234848 | ||
|
|
a5cbd036de | ||
|
|
260a514467 | ||
|
|
067509fa44 | ||
|
|
e84b20a527 | ||
|
|
4aaafe5322 | ||
|
|
d5ee766afe | ||
|
|
00c91c3678 | ||
|
|
4ab25a2889 | ||
|
|
14fb2c05b1 | ||
|
|
57d5a8df86 | ||
|
|
b6871d9c0f | ||
|
|
f03ea76db3 | ||
|
|
753491ab80 | ||
|
|
d00adfe98c | ||
|
|
2b2c3a071b | ||
|
|
f8fbeb52b0 | ||
|
|
cb46ea037f | ||
|
|
dece9e8b07 | ||
|
|
32221e194a | ||
|
|
5ecc364d55 | ||
|
|
0dbc51aa55 | ||
|
|
58cf37ceeb | ||
|
|
652318e56a | ||
|
|
d8691ff4ec | ||
|
|
8251f7c235 | ||
|
|
ae1880acf6 | ||
|
|
fddf8a6f4a | ||
|
|
412c1d0af1 | ||
|
|
166cf6a3e0 | ||
|
|
fd3d452f1f | ||
|
|
fdd0e78d1b | ||
|
|
60ce38d216 | ||
|
|
acb2a1ce37 | ||
|
|
ba3a0e7adb | ||
|
|
3a7b1b36b6 | ||
|
|
3830a4b58e | ||
|
|
6288c51774 | ||
|
|
a508c34731 | ||
|
|
5baa08ed13 | ||
|
|
55fd88e967 | ||
|
|
725f63f724 | ||
|
|
c82dc02b4d | ||
|
|
2cf060f774 | ||
|
|
5529473af9 | ||
|
|
5e3b211d93 | ||
|
|
3fd40fc5a3 | ||
|
|
f934725ccd | ||
|
|
5709b30700 | ||
|
|
2d5004cee4 | ||
|
|
1287abe0b5 | ||
|
|
a91bcd2cf4 | ||
|
|
67bfe8fb80 | ||
|
|
be4a490c23 | ||
|
|
e9ed5febc5 | ||
|
|
72baa58edd | ||
|
|
76015aab23 | ||
|
|
115cfb4430 | ||
|
|
ab000bc411 | ||
|
|
e3a93d6705 | ||
|
|
7857096d29 | ||
|
|
cedd520f25 | ||
|
|
4bdb857eca | ||
|
|
997b9ad232 | ||
|
|
e075a33ca3 | ||
|
|
c07036e813 | ||
|
|
b562aa6625 | ||
|
|
fe73878dfc | ||
|
|
ee2fa5f411 | ||
|
|
07dea4c6cc | ||
|
|
7bb0b7d1fc | ||
|
|
a90e007d50 | ||
|
|
94e84e6f75 | ||
|
|
e9c8540e21 | ||
|
|
961ca61b0e | ||
|
|
f809ff5e55 | ||
|
|
d27a763eec | ||
|
|
abd009b092 | ||
|
|
f0e373b82e | ||
|
|
35ab521e07 | ||
|
|
d8d9d3724f | ||
|
|
e3445f59c9 | ||
|
|
a68ed3f64c | ||
|
|
31980bcaf1 | ||
|
|
70f86e326d | ||
|
|
bed0e07620 | ||
|
|
632b71c7f8 | ||
|
|
eef13235ad | ||
|
|
89155aa6c6 | ||
|
|
bbcbabab74 | ||
|
|
0e2d8b8a1e | ||
|
|
c5288300a1 | ||
|
|
a7f6c95675 | ||
|
|
74294a4653 | ||
|
|
c59a472ca2 | ||
|
|
722bfaa9c9 | ||
|
|
37086d0c3e | ||
|
|
a1c50b4ee3 | ||
|
|
d75cd40787 | ||
|
|
34b088ede6 | ||
|
|
36b5f0c9a8 | ||
|
|
704c8ed530 | ||
|
|
2158b09b9d | ||
|
|
ed276d3e50 | ||
|
|
53ec78319d | ||
|
|
51709c63fe | ||
|
|
f8925b7588 | ||
|
|
023091ded3 | ||
|
|
ce922915ab | ||
|
|
f749365b1c | ||
|
|
4fc72226fa | ||
|
|
def74465eb | ||
|
|
a91553c7cf | ||
|
|
65ea200c31 | ||
|
|
0b56472cf5 | ||
|
|
8ba16a894f | ||
|
|
99909f7bc7 | ||
|
|
1b455b6d9f |
1
.github/workflows/formal-conformance.yml
vendored
1
.github/workflows/formal-conformance.yml
vendored
@@ -108,6 +108,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR (informational)
|
||||
if: steps.drift.outputs.drift == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
|
||||
24
.github/workflows/install-smoke.yml
vendored
24
.github/workflows/install-smoke.yml
vendored
@@ -33,19 +33,17 @@ jobs:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). 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: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -119,6 +119,19 @@
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## GHSA (Repo Advisory) Patch/Publish
|
||||
|
||||
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
|
||||
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
|
||||
- Private fork PRs must be closed:
|
||||
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
|
||||
`gh pr list -R "$fork" --state open` (must be empty)
|
||||
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
|
||||
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
|
||||
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
|
||||
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
|
||||
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
@@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Build: add `pnpm build:runtime` for packagers/runtime builds to skip plugin-sdk declaration generation when types are not needed. (#17636) Thanks @joshp123.
|
||||
- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
|
||||
- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
|
||||
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
|
||||
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
|
||||
@@ -14,6 +16,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
|
||||
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
@@ -28,8 +32,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
|
||||
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
|
||||
- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n.
|
||||
- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
|
||||
- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
|
||||
- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
|
||||
- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
|
||||
- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
|
||||
- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
@@ -106,6 +112,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz.
|
||||
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
|
||||
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
|
||||
- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc.
|
||||
- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
|
||||
- Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks.
|
||||
- Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.
|
||||
@@ -207,6 +214,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh.
|
||||
- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable.
|
||||
- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories.
|
||||
- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker.
|
||||
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
|
||||
- Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj.
|
||||
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
|
||||
@@ -337,6 +345,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445.
|
||||
- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
|
||||
- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
|
||||
- Gateway/Control UI: keep partial assistant output visible when runs are aborted, and persist aborted partials to session transcripts for follow-up context.
|
||||
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
|
||||
- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon.
|
||||
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
|
||||
|
||||
@@ -2087,6 +2087,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
public let notify: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let createdatms: Int
|
||||
public let updatedatms: Int
|
||||
@@ -2103,6 +2104,7 @@ public struct CronJob: Codable, Sendable {
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
notify: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
@@ -2118,6 +2120,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.notify = notify
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.createdatms = createdatms
|
||||
self.updatedatms = updatedatms
|
||||
@@ -2134,6 +2137,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
case notify
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case createdatms = "createdAtMs"
|
||||
case updatedatms = "updatedAtMs"
|
||||
@@ -2167,6 +2171,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let notify: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
@@ -2179,6 +2184,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
notify: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
@@ -2190,6 +2196,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.agentid = agentid
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.notify = notify
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
@@ -2202,6 +2209,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case description
|
||||
case enabled
|
||||
case notify
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
|
||||
@@ -2087,6 +2087,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
public let notify: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let createdatms: Int
|
||||
public let updatedatms: Int
|
||||
@@ -2103,6 +2104,7 @@ public struct CronJob: Codable, Sendable {
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
notify: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
@@ -2118,6 +2120,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.notify = notify
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.createdatms = createdatms
|
||||
self.updatedatms = updatedatms
|
||||
@@ -2134,6 +2137,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
case notify
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case createdatms = "createdAtMs"
|
||||
case updatedatms = "updatedAtMs"
|
||||
@@ -2167,6 +2171,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let notify: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
@@ -2179,6 +2184,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
notify: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
@@ -2190,6 +2196,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.agentid = agentid
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.notify = notify
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
@@ -2202,6 +2209,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case description
|
||||
case enabled
|
||||
case notify
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
|
||||
@@ -27,6 +27,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
||||
- **Main session**: enqueue a system event, then run on the next heartbeat.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
- Webhook posting is opt-in per job: set `notify: true` and configure `cron.webhook`.
|
||||
|
||||
## Quick start (actionable)
|
||||
|
||||
@@ -288,7 +289,7 @@ Notes:
|
||||
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
|
||||
- `everyMs` is milliseconds.
|
||||
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `notify`, `deleteAfterRun` (defaults to true for `at`),
|
||||
`delivery`.
|
||||
- `wakeMode` defaults to `"now"` when omitted.
|
||||
|
||||
@@ -333,10 +334,19 @@ Notes:
|
||||
enabled: true, // default true
|
||||
store: "~/.openclaw/cron/jobs.json",
|
||||
maxConcurrentRuns: 1, // default 1
|
||||
webhook: "https://example.invalid/cron-finished", // optional finished-run webhook endpoint
|
||||
webhookToken: "replace-with-dedicated-webhook-token", // optional, do not reuse gateway auth token
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Webhook behavior:
|
||||
|
||||
- The Gateway posts finished run events to `cron.webhook` only when the job has `notify: true`.
|
||||
- Payload is the cron finished event JSON.
|
||||
- If `cron.webhookToken` is set, auth header is `Authorization: Bearer <cron.webhookToken>`.
|
||||
- If `cron.webhookToken` is not set, no `Authorization` header is sent.
|
||||
|
||||
Disable cron entirely:
|
||||
|
||||
- `cron.enabled: false` (config)
|
||||
|
||||
@@ -105,7 +105,7 @@ Want “groups can only see folder X” instead of “no host access”? Keep `w
|
||||
docker: {
|
||||
binds: [
|
||||
// hostPath:containerPath:mode
|
||||
"~/FriendsShared:/data:ro",
|
||||
"/home/user/FriendsShared:/data:ro",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
192
docs/experiments/plans/pty-process-supervision.md
Normal file
192
docs/experiments/plans/pty-process-supervision.md
Normal file
@@ -0,0 +1,192 @@
|
||||
---
|
||||
summary: "Production plan for reliable interactive process supervision (PTY + non-PTY) with explicit ownership, unified lifecycle, and deterministic cleanup"
|
||||
owner: "openclaw"
|
||||
status: "in-progress"
|
||||
last_updated: "2026-02-15"
|
||||
title: "PTY and Process Supervision Plan"
|
||||
---
|
||||
|
||||
# PTY and Process Supervision Plan
|
||||
|
||||
## 1. Problem and goal
|
||||
|
||||
We need one reliable lifecycle for long-running command execution across:
|
||||
|
||||
- `exec` foreground runs
|
||||
- `exec` background runs
|
||||
- `process` follow up actions (`poll`, `log`, `send-keys`, `paste`, `submit`, `kill`, `remove`)
|
||||
- CLI agent runner subprocesses
|
||||
|
||||
The goal is not just to support PTY. The goal is predictable ownership, cancellation, timeout, and cleanup with no unsafe process matching heuristics.
|
||||
|
||||
## 2. Scope and boundaries
|
||||
|
||||
- Keep implementation internal in `src/process/supervisor`.
|
||||
- Do not create a new package for this.
|
||||
- Keep current behavior compatibility where practical.
|
||||
- Do not broaden scope to terminal replay or tmux style session persistence.
|
||||
|
||||
## 3. Implemented in this branch
|
||||
|
||||
### Supervisor baseline already present
|
||||
|
||||
- Supervisor module is in place under `src/process/supervisor/*`.
|
||||
- Exec runtime and CLI runner are already routed through supervisor spawn and wait.
|
||||
- Registry finalization is idempotent.
|
||||
|
||||
### This pass completed
|
||||
|
||||
1. Explicit PTY command contract
|
||||
|
||||
- `SpawnInput` is now a discriminated union in `src/process/supervisor/types.ts`.
|
||||
- PTY runs require `ptyCommand` instead of reusing generic `argv`.
|
||||
- Supervisor no longer rebuilds PTY command strings from argv joins in `src/process/supervisor/supervisor.ts`.
|
||||
- Exec runtime now passes `ptyCommand` directly in `src/agents/bash-tools.exec-runtime.ts`.
|
||||
|
||||
2. Process layer type decoupling
|
||||
|
||||
- Supervisor types no longer import `SessionStdin` from agents.
|
||||
- Process local stdin contract lives in `src/process/supervisor/types.ts` (`ManagedRunStdin`).
|
||||
- Adapters now depend only on process level types:
|
||||
- `src/process/supervisor/adapters/child.ts`
|
||||
- `src/process/supervisor/adapters/pty.ts`
|
||||
|
||||
3. Process tool lifecycle ownership improvement
|
||||
|
||||
- `src/agents/bash-tools.process.ts` now requests cancellation through supervisor first.
|
||||
- `process kill/remove` now use process-tree fallback termination when supervisor lookup misses.
|
||||
- `remove` keeps deterministic remove behavior by dropping running session entries immediately after termination is requested.
|
||||
|
||||
4. Single source watchdog defaults
|
||||
|
||||
- Added shared defaults in `src/agents/cli-watchdog-defaults.ts`.
|
||||
- `src/agents/cli-backends.ts` consumes the shared defaults.
|
||||
- `src/agents/cli-runner/reliability.ts` consumes the same shared defaults.
|
||||
|
||||
5. Dead helper cleanup
|
||||
|
||||
- Removed unused `killSession` helper path from `src/agents/bash-tools.shared.ts`.
|
||||
|
||||
6. Direct supervisor path tests added
|
||||
|
||||
- Added `src/agents/bash-tools.process.supervisor.test.ts` to cover kill and remove routing through supervisor cancellation.
|
||||
|
||||
7. Reliability gap fixes completed
|
||||
|
||||
- `src/agents/bash-tools.process.ts` now falls back to real OS-level process termination when supervisor lookup misses.
|
||||
- `src/process/supervisor/adapters/child.ts` now uses process-tree termination semantics for default cancel/timeout kill paths.
|
||||
- Added shared process-tree utility in `src/process/kill-tree.ts`.
|
||||
|
||||
8. PTY contract edge-case coverage added
|
||||
|
||||
- Added `src/process/supervisor/supervisor.pty-command.test.ts` for verbatim PTY command forwarding and empty-command rejection.
|
||||
- Added `src/process/supervisor/adapters/child.test.ts` for process-tree kill behavior in child adapter cancellation.
|
||||
|
||||
## 4. Remaining gaps and decisions
|
||||
|
||||
### Reliability status
|
||||
|
||||
The two required reliability gaps for this pass are now closed:
|
||||
|
||||
- `process kill/remove` now has a real OS termination fallback when supervisor lookup misses.
|
||||
- child cancel/timeout now uses process-tree kill semantics for default kill path.
|
||||
- Regression tests were added for both behaviors.
|
||||
|
||||
### Durability and startup reconciliation
|
||||
|
||||
Restart behavior is now explicitly defined as in-memory lifecycle only.
|
||||
|
||||
- `reconcileOrphans()` remains a no-op in `src/process/supervisor/supervisor.ts` by design.
|
||||
- Active runs are not recovered after process restart.
|
||||
- This boundary is intentional for this implementation pass to avoid partial persistence risks.
|
||||
|
||||
### Maintainability follow-ups
|
||||
|
||||
1. `runExecProcess` in `src/agents/bash-tools.exec-runtime.ts` still handles multiple responsibilities and can be split into focused helpers in a follow-up.
|
||||
|
||||
## 5. Implementation plan
|
||||
|
||||
The implementation pass for required reliability and contract items is complete.
|
||||
|
||||
Completed:
|
||||
|
||||
- `process kill/remove` fallback real termination
|
||||
- process-tree cancellation for child adapter default kill path
|
||||
- regression tests for fallback kill and child adapter kill path
|
||||
- PTY command edge-case tests under explicit `ptyCommand`
|
||||
- explicit in-memory restart boundary with `reconcileOrphans()` no-op by design
|
||||
|
||||
Optional follow-up:
|
||||
|
||||
- split `runExecProcess` into focused helpers with no behavior drift
|
||||
|
||||
## 6. File map
|
||||
|
||||
### Process supervisor
|
||||
|
||||
- `src/process/supervisor/types.ts` updated with discriminated spawn input and process local stdin contract.
|
||||
- `src/process/supervisor/supervisor.ts` updated to use explicit `ptyCommand`.
|
||||
- `src/process/supervisor/adapters/child.ts` and `src/process/supervisor/adapters/pty.ts` decoupled from agent types.
|
||||
- `src/process/supervisor/registry.ts` idempotent finalize unchanged and retained.
|
||||
|
||||
### Exec and process integration
|
||||
|
||||
- `src/agents/bash-tools.exec-runtime.ts` updated to pass PTY command explicitly and keep fallback path.
|
||||
- `src/agents/bash-tools.process.ts` updated to cancel via supervisor with real process-tree fallback termination.
|
||||
- `src/agents/bash-tools.shared.ts` removed direct kill helper path.
|
||||
|
||||
### CLI reliability
|
||||
|
||||
- `src/agents/cli-watchdog-defaults.ts` added as shared baseline.
|
||||
- `src/agents/cli-backends.ts` and `src/agents/cli-runner/reliability.ts` now consume same defaults.
|
||||
|
||||
## 7. Validation run in this pass
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `pnpm vitest src/process/supervisor/registry.test.ts`
|
||||
- `pnpm vitest src/process/supervisor/supervisor.test.ts`
|
||||
- `pnpm vitest src/process/supervisor/supervisor.pty-command.test.ts`
|
||||
- `pnpm vitest src/process/supervisor/adapters/child.test.ts`
|
||||
- `pnpm vitest src/agents/cli-backends.test.ts`
|
||||
- `pnpm vitest src/agents/bash-tools.exec.pty-cleanup.test.ts`
|
||||
- `pnpm vitest src/agents/bash-tools.process.poll-timeout.test.ts`
|
||||
- `pnpm vitest src/agents/bash-tools.process.supervisor.test.ts`
|
||||
- `pnpm vitest src/process/exec.test.ts`
|
||||
|
||||
E2E targets:
|
||||
|
||||
- `pnpm test:e2e src/agents/cli-runner.e2e.test.ts`
|
||||
- `pnpm test:e2e src/agents/bash-tools.exec.pty-fallback.e2e.test.ts src/agents/bash-tools.exec.background-abort.e2e.test.ts src/agents/bash-tools.process.send-keys.e2e.test.ts`
|
||||
|
||||
Typecheck note:
|
||||
|
||||
- `pnpm tsgo` currently fails in this repo due to a pre-existing UI typing dependency issue (`@vitest/browser-playwright` resolution), unrelated to this process supervision work.
|
||||
|
||||
## 8. Operational guarantees preserved
|
||||
|
||||
- Exec env hardening behavior is unchanged.
|
||||
- Approval and allowlist flow is unchanged.
|
||||
- Output sanitization and output caps are unchanged.
|
||||
- PTY adapter still guarantees wait settlement on forced kill and listener disposal.
|
||||
|
||||
## 9. Definition of done
|
||||
|
||||
1. Supervisor is lifecycle owner for managed runs.
|
||||
2. PTY spawn uses explicit command contract with no argv reconstruction.
|
||||
3. Process layer has no type dependency on agent layer for supervisor stdin contracts.
|
||||
4. Watchdog defaults are single source.
|
||||
5. Targeted unit and e2e tests remain green.
|
||||
6. Restart durability boundary is explicitly documented or fully implemented.
|
||||
|
||||
## 10. Summary
|
||||
|
||||
The branch now has a coherent and safer supervision shape:
|
||||
|
||||
- explicit PTY contract
|
||||
- cleaner process layering
|
||||
- supervisor driven cancellation path for process operations
|
||||
- real fallback termination when supervisor lookup misses
|
||||
- process-tree cancellation for child-run default kill paths
|
||||
- unified watchdog defaults
|
||||
- explicit in-memory restart boundary (no orphan reconciliation across restart in this pass)
|
||||
@@ -2295,12 +2295,16 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
|
||||
cron: {
|
||||
enabled: true,
|
||||
maxConcurrentRuns: 2,
|
||||
webhook: "https://example.invalid/cron-finished", // optional, must be http:// or https://
|
||||
webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth
|
||||
sessionRetention: "24h", // duration string or false
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`.
|
||||
- `webhook`: finished-run webhook endpoint, only used when the job has `notify: true`.
|
||||
- `webhookToken`: dedicated bearer token for webhook auth, if omitted no auth header is sent.
|
||||
|
||||
See [Cron Jobs](/automation/cron-jobs).
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Global and per-agent binds are **merged** (not replaced). Under `scope: "shared"
|
||||
- When set (including `[]`), it replaces `agents.defaults.sandbox.docker.binds` for the browser container.
|
||||
- When omitted, the browser container falls back to `agents.defaults.sandbox.docker.binds` (backwards compatible).
|
||||
|
||||
Example (read-only source + docker socket):
|
||||
Example (read-only source + an extra data directory):
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -84,7 +84,7 @@ Example (read-only source + docker socket):
|
||||
defaults: {
|
||||
sandbox: {
|
||||
docker: {
|
||||
binds: ["/home/user/source:/source:ro", "/var/run/docker.sock:/var/run/docker.sock"],
|
||||
binds: ["/home/user/source:/source:ro", "/var/data/myapp:/data:ro"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -105,7 +105,8 @@ Example (read-only source + docker socket):
|
||||
Security notes:
|
||||
|
||||
- Binds bypass the sandbox filesystem: they expose host paths with whatever mode you set (`:ro` or `:rw`).
|
||||
- Sensitive mounts (e.g., `docker.sock`, secrets, SSH keys) should be `:ro` unless absolutely required.
|
||||
- OpenClaw blocks dangerous bind sources (for example: `docker.sock`, `/etc`, `/proc`, `/sys`, `/dev`, and parent mounts that would expose them).
|
||||
- Sensitive mounts (secrets, SSH keys, service credentials) should be `:ro` unless absolutely required.
|
||||
- Combine with `workspaceAccess: "ro"` if you only need read access to the workspace; bind modes stay independent.
|
||||
- See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for how binds interact with tool policy and elevated exec.
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ Fetch a URL and extract readable content.
|
||||
enabled: true,
|
||||
maxChars: 50000,
|
||||
maxCharsCap: 50000,
|
||||
maxResponseBytes: 2000000,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
maxRedirects: 3,
|
||||
@@ -256,6 +257,7 @@ Notes:
|
||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
|
||||
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.
|
||||
- `web_fetch` caps the downloaded response body size to `tools.web.fetch.maxResponseBytes` before parsing; oversized responses are truncated and include a warning.
|
||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||
|
||||
@@ -83,6 +83,9 @@ Cron jobs panel notes:
|
||||
|
||||
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
|
||||
- Channel/target fields appear when announce is selected.
|
||||
- New job form includes a **Notify webhook** toggle (`notify` on the job).
|
||||
- Gateway webhook posting requires both `notify: true` on the job and `cron.webhook` in config.
|
||||
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
|
||||
|
||||
## Chat behavior
|
||||
|
||||
@@ -93,6 +96,10 @@ Cron jobs panel notes:
|
||||
- Click **Stop** (calls `chat.abort`)
|
||||
- Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band
|
||||
- `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session
|
||||
- Abort partial retention:
|
||||
- When a run is aborted, partial assistant text can still be shown in the UI
|
||||
- Gateway persists aborted partial assistant text into transcript history when buffered output exists
|
||||
- Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output
|
||||
|
||||
## Tailnet access (recommended)
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
||||
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
|
||||
- Aborted runs can keep partial assistant output visible in the UI.
|
||||
- Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata.
|
||||
- History is always fetched from the gateway (no local file watching).
|
||||
- If the gateway is unreachable, WebChat is read-only.
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
|
||||
"build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||
"build:runtime": "pnpm canvas:a2ui:bundle && tsdown && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint",
|
||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
||||
@@ -177,13 +178,13 @@
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260214.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260215.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"lit": "^3.3.2",
|
||||
"ollama": "^0.6.3",
|
||||
"oxfmt": "0.32.0",
|
||||
"oxlint": "^1.47.0",
|
||||
"oxlint-tsgolint": "^0.12.2",
|
||||
"oxlint-tsgolint": "^0.13.0",
|
||||
"rolldown": "1.0.0-rc.4",
|
||||
"tsdown": "^0.20.3",
|
||||
"tsx": "^4.21.0",
|
||||
|
||||
148
pnpm-lock.yaml
generated
148
pnpm-lock.yaml
generated
@@ -207,8 +207,8 @@ importers:
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260214.1
|
||||
version: 7.0.0-dev.20260214.1
|
||||
specifier: 7.0.0-dev.20260215.1
|
||||
version: 7.0.0-dev.20260215.1
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
|
||||
@@ -223,16 +223,16 @@ importers:
|
||||
version: 0.32.0
|
||||
oxlint:
|
||||
specifier: ^1.47.0
|
||||
version: 1.47.0(oxlint-tsgolint@0.12.2)
|
||||
version: 1.47.0(oxlint-tsgolint@0.13.0)
|
||||
oxlint-tsgolint:
|
||||
specifier: ^0.12.2
|
||||
version: 0.12.2
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
rolldown:
|
||||
specifier: 1.0.0-rc.4
|
||||
version: 1.0.0-rc.4
|
||||
tsdown:
|
||||
specifier: ^0.20.3
|
||||
version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260214.1)(typescript@5.9.3)
|
||||
version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260215.1)(typescript@5.9.3)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
@@ -783,8 +783,8 @@ packages:
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@8.0.0-rc.1':
|
||||
resolution: {integrity: sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw==}
|
||||
'@babel/helper-string-parser@8.0.0-rc.2':
|
||||
resolution: {integrity: sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
@@ -2059,33 +2059,33 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.12.2':
|
||||
resolution: {integrity: sha512-XIfavTqkJPGYi/98z7ZCkZvXq2AccMAAB0iwvKDRTQqiweMXVUyeUdx46phCHHH1PgmIVJtVfysThkHq2xCyrw==}
|
||||
'@oxlint-tsgolint/darwin-arm64@0.13.0':
|
||||
resolution: {integrity: sha512-OWQ3U+oDjjupmX0WU9oYyKF2iUOKDMLW/+zan0cd0vYIGId80xTRHHA8oXnREmK8dsMMP3nV3VXME3NH/hS0lw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.12.2':
|
||||
resolution: {integrity: sha512-tytsvP6zmNShRNDo4GgQartOXmd4GPd+TylCUMdO/iWl9PZVOgRyswWbYVTNgn85Cib/aY2q3Uu+jOw+QlbxvQ==}
|
||||
'@oxlint-tsgolint/darwin-x64@0.13.0':
|
||||
resolution: {integrity: sha512-wZvgj+eVqNkCUjSq2ExlMdbGDpZfaw6J+YctQV1pkGFdn7Y9cySWdfwu5v/AW2JPsJbFMXJ8GAr+WoZbRapz2A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.12.2':
|
||||
resolution: {integrity: sha512-3W38yJuF7taEquhEuD6mYQyCeWNAlc1pNPjFkspkhLKZVgbrhDA4V6fCxLDDRvrTHde0bXPmFvuPlUq5pSePgA==}
|
||||
'@oxlint-tsgolint/linux-arm64@0.13.0':
|
||||
resolution: {integrity: sha512-nwtf5BgHbAWSVwyIF00l6QpfyFcpDMp6D+3cpe6NTgBYMSSSC0Ip1gswUwzVccOPoQK48t+J6vHyURQ96M1KDg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.12.2':
|
||||
resolution: {integrity: sha512-EjcEspeeV0NmaopEp4wcN5ntQP9VCJJDrTvzOjMP4W6ajz18M+pni9vkKvmcPIpRa/UmWobeFgKoVd/KGueeuQ==}
|
||||
'@oxlint-tsgolint/linux-x64@0.13.0':
|
||||
resolution: {integrity: sha512-Rkzgj38eVoGSBuGDaCrALS4FM19+m1Qlv0hjB4MWvXUej014XkB5ze+svYE3HX+AAm1ey9QYj/CQzfz203FPIg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.12.2':
|
||||
resolution: {integrity: sha512-a9L7iA5K/Ht/i8d9+7RTp6hbPa4cyXP0MdySVXAO6vczpL/4ildfY9Hr2m2wqL12uK6xe/uVABpVTrqay/wV+g==}
|
||||
'@oxlint-tsgolint/win32-arm64@0.13.0':
|
||||
resolution: {integrity: sha512-Y+0hFqLT5M7UIvGvTR3QFK27l17FqXk6UwwpBFOcyBGJ5bLd1RaAPWjqTmcgPvdolA6FCMeW1pxZuNtKDlYd7A==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.12.2':
|
||||
resolution: {integrity: sha512-Cvt40UbTf5ib12DjGN+mMGOnjWa4Bc6Y7KEaXXp9qzckvs3HpNk2wSwMV3gnuR8Ipx4hkzkzrgzD0BAUsySAfA==}
|
||||
'@oxlint-tsgolint/win32-x64@0.13.0':
|
||||
resolution: {integrity: sha512-mXjTttzyyfl8d/XvxggmZFBq0pbQmRvHbjQEv70YECNaLEHG8j8WYUwLa641uudAnV1VoBI34pc7bmgJM7qhOA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -2995,43 +2995,43 @@ packages:
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260214.1':
|
||||
resolution: {integrity: sha512-Jb2WcLGpTOC6x58e8QPYC/14xmDbnbFIuKqUvYoI77hVtojVyxZi8L5Y4CgYqXYx8vRWmIFk35c1OGdtPip6Sg==}
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260215.1':
|
||||
resolution: {integrity: sha512-icVO/hEMXjWlKhmpjIpqDyCzPvtHqfrPB+2rkd6M3rz84Bmw+o8Xgd7JvRxryZhR+D0y55me/bKh9xgvsgzuhA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260214.1':
|
||||
resolution: {integrity: sha512-O9l2gVuQFZsb8NIQtu0HN5Tn/Hw2fwylPOPS/0Y4oW+FUMhkqtvetUkb3zZ0qj7capilZ4YnmyGYg3TDqkP4Nw==}
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260215.1':
|
||||
resolution: {integrity: sha512-Wz73wf1o9+4KwCLg8wnnIZZDAvv2KRZlDyP4X8GfBNzajfIAwYvI0ANWuIDznUUGeDAcqhBJXNe0Bkf4H9y4mg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260214.1':
|
||||
resolution: {integrity: sha512-Hl4e3yxJqzIGgFI8aH/rLGW+a7kSLHJCpAd5JOLG7hHKnamZF4SjlunnoHLV4IcMri+G6UE3W/84i0QvQP5wLA==}
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260215.1':
|
||||
resolution: {integrity: sha512-AYyXRxVwLZzfkEYN8FGdV4vqXwbTmv93nAZ6gMLvpDG4ItOybAE1R2obFjlFc+Or/rfQmVvfdkTym3c4bRJ3XQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260214.1':
|
||||
resolution: {integrity: sha512-TaFrVnx3iXtl/oH1hzwvFyqWj9tzkjW8Ufl2m0Vx2/7GXnzZadm2KA6tFpGbzzWbZJznmXxKHL4O3AZRQYyZqQ==}
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260215.1':
|
||||
resolution: {integrity: sha512-6WVXFVSp3LBBiBgBMtAHQgTDN72mDhgjrmXH7GoABTxR9asK8oPfmy5cwTp1sPD46pYhqjnSHMrARyg2FaNSeA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260214.1':
|
||||
resolution: {integrity: sha512-a/JypIXTc/tdodhYdQm24WH6aTfnJJjDbwxce4BS2g6IzYSc2GFcZBvlq1CJYS2FAVLpiSxj0OFAZmgjpCDAKg==}
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260215.1':
|
||||
resolution: {integrity: sha512-Ui6qbTO+nE7fwh5OGTGfL4ndaT+SpiUiv0F1m3+nMaiAKysY5GbgXUfzWzkSrOODsT8F/4jZ4wCzEzJordt8sQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260214.1':
|
||||
resolution: {integrity: sha512-MJGPEDvdXj8olcWH0P+cWYcaN4r/0J4aSbcaISlen3MZ/2hrrgNl46PV4eGJKKCDniY2pH2fJzrMyJWZOcdb0w==}
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260215.1':
|
||||
resolution: {integrity: sha512-dBFyAH9h3bMUaIp/84c3gKwyQ6jQmtzVoIBamSrYNw0xinJ56A/Ln5igdNOYrH8+/Aofmeh7pAWaa8U456XMjw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260214.1':
|
||||
resolution: {integrity: sha512-BtF48TRUyiCKznlOcQ7r7EXhonGSanm9X2eu7d8Yq1vaWO5SDgB0e+ISQXSoIfs3a1S3d5S5QV/vTE4+vocPxA==}
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260215.1':
|
||||
resolution: {integrity: sha512-bEMSwX71OGGvfsfHEa/aX7ZUWbPSI2oKEmeWcDQVY8vH1VK1ZwcFzMhKfgVJPt5pKH2bK3EO3xYnAyKkDO/Ung==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260214.1':
|
||||
resolution: {integrity: sha512-BDM0ZLf2v6ilR0tDi8OMEr4X08lFCToPk3/p1SSE4GhagzmlU/5b+9slR0kKtaKMrds01FhvaKx6U9+NmAWgbQ==}
|
||||
'@typescript/native-preview@7.0.0-dev.20260215.1':
|
||||
resolution: {integrity: sha512-grs0BbJyPR7VLNerBVteEToPku1InMKVKVKBUTJi19LfK+LU3+pkU6/fsTfZhH3xmIzIxD/sNRQHLt4x/Yb9yg==}
|
||||
hasBin: true
|
||||
|
||||
'@typespec/ts-http-runtime@0.3.3':
|
||||
@@ -4728,8 +4728,8 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
oxlint-tsgolint@0.12.2:
|
||||
resolution: {integrity: sha512-IFiOhYZfSgiHbBznTZOhFpEHpsZFSP0j7fVRake03HEkgH0YljnTFDNoRkGWsTrnrHr7nRIomSsF4TnCI/O+kQ==}
|
||||
oxlint-tsgolint@0.13.0:
|
||||
resolution: {integrity: sha512-VUOWP5T9R9RwuPLKvNgvhsjdPFVhr2k8no8ea84+KhDtYPmk9L/3StNP3WClyPOKJOT8bFlO3eyhTKxXK9+Oog==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.47.0:
|
||||
@@ -6285,7 +6285,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-string-parser@8.0.0-rc.1': {}
|
||||
'@babel/helper-string-parser@8.0.0-rc.2': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
@@ -6308,7 +6308,7 @@ snapshots:
|
||||
|
||||
'@babel/types@8.0.0-rc.1':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 8.0.0-rc.1
|
||||
'@babel/helper-string-parser': 8.0.0-rc.2
|
||||
'@babel/helper-validator-identifier': 8.0.0-rc.1
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
@@ -7546,22 +7546,22 @@ snapshots:
|
||||
'@oxfmt/binding-win32-x64-msvc@0.32.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.12.2':
|
||||
'@oxlint-tsgolint/darwin-arm64@0.13.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.12.2':
|
||||
'@oxlint-tsgolint/darwin-x64@0.13.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.12.2':
|
||||
'@oxlint-tsgolint/linux-arm64@0.13.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.12.2':
|
||||
'@oxlint-tsgolint/linux-x64@0.13.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.12.2':
|
||||
'@oxlint-tsgolint/win32-arm64@0.13.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.12.2':
|
||||
'@oxlint-tsgolint/win32-x64@0.13.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.47.0':
|
||||
@@ -8473,36 +8473,36 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260214.1':
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260215.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260214.1':
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260215.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260214.1':
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260215.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260214.1':
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260215.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260214.1':
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260215.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260214.1':
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260215.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260214.1':
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260215.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260214.1':
|
||||
'@typescript/native-preview@7.0.0-dev.20260215.1':
|
||||
optionalDependencies:
|
||||
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260214.1
|
||||
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260214.1
|
||||
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260214.1
|
||||
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260214.1
|
||||
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260214.1
|
||||
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260214.1
|
||||
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260214.1
|
||||
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260215.1
|
||||
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260215.1
|
||||
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260215.1
|
||||
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260215.1
|
||||
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260215.1
|
||||
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260215.1
|
||||
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260215.1
|
||||
|
||||
'@typespec/ts-http-runtime@0.3.3':
|
||||
dependencies:
|
||||
@@ -10385,16 +10385,16 @@ snapshots:
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.32.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.32.0
|
||||
|
||||
oxlint-tsgolint@0.12.2:
|
||||
oxlint-tsgolint@0.13.0:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.12.2
|
||||
'@oxlint-tsgolint/darwin-x64': 0.12.2
|
||||
'@oxlint-tsgolint/linux-arm64': 0.12.2
|
||||
'@oxlint-tsgolint/linux-x64': 0.12.2
|
||||
'@oxlint-tsgolint/win32-arm64': 0.12.2
|
||||
'@oxlint-tsgolint/win32-x64': 0.12.2
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.13.0
|
||||
'@oxlint-tsgolint/darwin-x64': 0.13.0
|
||||
'@oxlint-tsgolint/linux-arm64': 0.13.0
|
||||
'@oxlint-tsgolint/linux-x64': 0.13.0
|
||||
'@oxlint-tsgolint/win32-arm64': 0.13.0
|
||||
'@oxlint-tsgolint/win32-x64': 0.13.0
|
||||
|
||||
oxlint@1.47.0(oxlint-tsgolint@0.12.2):
|
||||
oxlint@1.47.0(oxlint-tsgolint@0.13.0):
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.47.0
|
||||
'@oxlint/binding-android-arm64': 1.47.0
|
||||
@@ -10415,7 +10415,7 @@ snapshots:
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.47.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.47.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.47.0
|
||||
oxlint-tsgolint: 0.12.2
|
||||
oxlint-tsgolint: 0.13.0
|
||||
|
||||
p-finally@1.0.0: {}
|
||||
|
||||
@@ -10791,7 +10791,7 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 10.5.0
|
||||
|
||||
rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260214.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3):
|
||||
rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260215.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/generator': 8.0.0-rc.1
|
||||
'@babel/helper-validator-identifier': 8.0.0-rc.1
|
||||
@@ -10804,7 +10804,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
rolldown: 1.0.0-rc.3
|
||||
optionalDependencies:
|
||||
'@typescript/native-preview': 7.0.0-dev.20260214.1
|
||||
'@typescript/native-preview': 7.0.0-dev.20260215.1
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- oxc-resolver
|
||||
@@ -11269,7 +11269,7 @@ snapshots:
|
||||
|
||||
ts-algebra@2.0.0: {}
|
||||
|
||||
tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260214.1)(typescript@5.9.3):
|
||||
tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260215.1)(typescript@5.9.3):
|
||||
dependencies:
|
||||
ansis: 4.2.0
|
||||
cac: 6.7.14
|
||||
@@ -11280,7 +11280,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
picomatch: 4.0.3
|
||||
rolldown: 1.0.0-rc.3
|
||||
rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260214.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3)
|
||||
rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260215.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3)
|
||||
semver: 7.7.4
|
||||
tinyexec: 1.0.2
|
||||
tinyglobby: 0.2.15
|
||||
|
||||
@@ -165,7 +165,7 @@ const defaultWorkerBudget =
|
||||
unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))),
|
||||
unitIsolated: 1,
|
||||
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
|
||||
gateway: 1,
|
||||
gateway: 2,
|
||||
};
|
||||
|
||||
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
|
||||
function createGateway(resolveLabelKey = "agent:main:label"): {
|
||||
gateway: GatewayClient;
|
||||
@@ -54,3 +55,26 @@ describe("acp session mapper", () => {
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp session manager", () => {
|
||||
const store = createInMemorySessionStore();
|
||||
|
||||
afterEach(() => {
|
||||
store.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("tracks active runs and clears on cancel", () => {
|
||||
const session = store.createSession({
|
||||
sessionKey: "acp:test",
|
||||
cwd: "/tmp",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
store.setActiveRun(session.sessionId, "run-1", controller);
|
||||
|
||||
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
|
||||
|
||||
const cancelled = store.cancelActiveRun(session.sessionId);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(store.getSessionByRunId("run-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
|
||||
describe("acp session manager", () => {
|
||||
const store = createInMemorySessionStore();
|
||||
|
||||
afterEach(() => {
|
||||
store.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("tracks active runs and clears on cancel", () => {
|
||||
const session = store.createSession({
|
||||
sessionKey: "acp:test",
|
||||
cwd: "/tmp",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
store.setActiveRun(session.sessionId, "run-1", controller);
|
||||
|
||||
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
|
||||
|
||||
const cancelled = store.cancelActiveRun(session.sessionId);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(store.getSessionByRunId("run-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
@@ -10,10 +11,7 @@ import {
|
||||
import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js";
|
||||
|
||||
describe("auth-profiles (chutes)", () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
|
||||
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -22,29 +20,17 @@ describe("auth-profiles (chutes)", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
if (previousChutesClientId === undefined) {
|
||||
delete process.env.CHUTES_CLIENT_ID;
|
||||
} else {
|
||||
process.env.CHUTES_CLIENT_ID = previousChutesClientId;
|
||||
}
|
||||
envSnapshot?.restore();
|
||||
envSnapshot = undefined;
|
||||
});
|
||||
|
||||
it("refreshes expired Chutes OAuth credentials", async () => {
|
||||
envSnapshot = captureEnv([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_AGENT_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
"CHUTES_CLIENT_ID",
|
||||
]);
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chutes-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
||||
|
||||
@@ -3,13 +3,16 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import { resolveApiKeyForProfile } from "./oauth.js";
|
||||
import { ensureAuthProfileStore } from "./store.js";
|
||||
|
||||
describe("resolveApiKeyForProfile fallback to main agent", () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_AGENT_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
]);
|
||||
let tmpDir: string;
|
||||
let mainAgentDir: string;
|
||||
let secondaryAgentDir: string;
|
||||
@@ -30,22 +33,7 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
// Restore original environment
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import path from "node:path";
|
||||
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
|
||||
import type { ProcessSession, SessionStdin } from "./bash-process-registry.js";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import type { ExecToolDetails } from "./bash-tools.exec.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { mergePathPrepend } from "../infra/path-prepend.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
export { applyPathPrepend, normalizePathPrepend } from "../infra/path-prepend.js";
|
||||
import type { ManagedRun } from "../process/supervisor/index.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
|
||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||
import {
|
||||
addSession,
|
||||
appendOutput,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
buildDockerExecArgs,
|
||||
chunkString,
|
||||
clampWithDefault,
|
||||
killSession,
|
||||
readEnvInt,
|
||||
} from "./bash-tools.shared.js";
|
||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
@@ -147,26 +146,6 @@ export const execSchema = Type.Object({
|
||||
),
|
||||
});
|
||||
|
||||
type PtyExitEvent = { exitCode: number; signal?: number };
|
||||
type PtyListener<T> = (event: T) => void;
|
||||
type PtyHandle = {
|
||||
pid: number;
|
||||
write: (data: string | Buffer) => void;
|
||||
onData: (listener: PtyListener<string>) => void;
|
||||
onExit: (listener: PtyListener<PtyExitEvent>) => void;
|
||||
};
|
||||
type PtySpawn = (
|
||||
file: string,
|
||||
args: string[] | string,
|
||||
options: {
|
||||
name?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
},
|
||||
) => PtyHandle;
|
||||
|
||||
export type ExecProcessOutcome = {
|
||||
status: "completed" | "failed";
|
||||
exitCode: number | null;
|
||||
@@ -319,138 +298,10 @@ export async function runExecProcess(opts: {
|
||||
}): Promise<ExecProcessHandle> {
|
||||
const startedAt = Date.now();
|
||||
const sessionId = createSessionSlug();
|
||||
let child: ChildProcessWithoutNullStreams | null = null;
|
||||
let pty: PtyHandle | null = null;
|
||||
let stdin: SessionStdin | undefined;
|
||||
const execCommand = opts.execCommand ?? opts.command;
|
||||
const supervisor = getProcessSupervisor();
|
||||
|
||||
const spawnFallbacks = [
|
||||
{
|
||||
label: "no-detach",
|
||||
options: { detached: false },
|
||||
},
|
||||
];
|
||||
|
||||
const handleSpawnFallback = (err: unknown, fallback: { label: string }) => {
|
||||
const errText = formatSpawnError(err);
|
||||
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
|
||||
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
|
||||
opts.warnings.push(warning);
|
||||
};
|
||||
|
||||
const spawnShellChild = async (
|
||||
shell: string,
|
||||
shellArgs: string[],
|
||||
): Promise<ChildProcessWithoutNullStreams> => {
|
||||
const { child: spawned } = await spawnWithFallback({
|
||||
argv: [shell, ...shellArgs, execCommand],
|
||||
options: {
|
||||
cwd: opts.workdir,
|
||||
env: opts.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
fallbacks: spawnFallbacks,
|
||||
onFallback: handleSpawnFallback,
|
||||
});
|
||||
return spawned as ChildProcessWithoutNullStreams;
|
||||
};
|
||||
|
||||
// `exec` does not currently accept tool-provided stdin content. For non-PTY runs,
|
||||
// keeping stdin open can cause commands like `wc -l` (or safeBins-hardened segments)
|
||||
// to block forever waiting for input, leading to accidental backgrounding.
|
||||
// For interactive flows, callers should use `pty: true` (stdin kept open).
|
||||
const maybeCloseNonPtyStdin = () => {
|
||||
if (opts.usePty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Signal EOF immediately so stdin-only commands can terminate.
|
||||
child?.stdin?.end();
|
||||
} catch {
|
||||
// ignore stdin close errors
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.sandbox) {
|
||||
const { child: spawned } = await spawnWithFallback({
|
||||
argv: [
|
||||
"docker",
|
||||
...buildDockerExecArgs({
|
||||
containerName: opts.sandbox.containerName,
|
||||
command: execCommand,
|
||||
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
||||
env: opts.env,
|
||||
tty: opts.usePty,
|
||||
}),
|
||||
],
|
||||
options: {
|
||||
cwd: opts.workdir,
|
||||
env: process.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
fallbacks: spawnFallbacks,
|
||||
onFallback: handleSpawnFallback,
|
||||
});
|
||||
child = spawned as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
maybeCloseNonPtyStdin();
|
||||
} else if (opts.usePty) {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
try {
|
||||
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
|
||||
spawn?: PtySpawn;
|
||||
default?: { spawn?: PtySpawn };
|
||||
};
|
||||
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
||||
if (!spawnPty) {
|
||||
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
||||
}
|
||||
pty = spawnPty(shell, [...shellArgs, execCommand], {
|
||||
cwd: opts.workdir,
|
||||
env: opts.env,
|
||||
name: process.env.TERM ?? "xterm-256color",
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
stdin = {
|
||||
destroyed: false,
|
||||
write: (data, cb) => {
|
||||
try {
|
||||
pty?.write(data);
|
||||
cb?.(null);
|
||||
} catch (err) {
|
||||
cb?.(err as Error);
|
||||
}
|
||||
},
|
||||
end: () => {
|
||||
try {
|
||||
const eof = process.platform === "win32" ? "\x1a" : "\x04";
|
||||
pty?.write(eof);
|
||||
} catch {
|
||||
// ignore EOF errors
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const errText = String(err);
|
||||
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
|
||||
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
|
||||
opts.warnings.push(warning);
|
||||
child = await spawnShellChild(shell, shellArgs);
|
||||
stdin = child.stdin;
|
||||
}
|
||||
} else {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
child = await spawnShellChild(shell, shellArgs);
|
||||
stdin = child.stdin;
|
||||
maybeCloseNonPtyStdin();
|
||||
}
|
||||
|
||||
const session = {
|
||||
const session: ProcessSession = {
|
||||
id: sessionId,
|
||||
command: opts.command,
|
||||
scopeKey: opts.scopeKey,
|
||||
@@ -458,9 +309,9 @@ export async function runExecProcess(opts: {
|
||||
notifyOnExit: opts.notifyOnExit,
|
||||
notifyOnExitEmptySuccess: opts.notifyOnExitEmptySuccess === true,
|
||||
exitNotified: false,
|
||||
child: child ?? undefined,
|
||||
stdin,
|
||||
pid: child?.pid ?? pty?.pid,
|
||||
child: undefined,
|
||||
stdin: undefined,
|
||||
pid: undefined,
|
||||
startedAt,
|
||||
cwd: opts.workdir,
|
||||
maxOutputChars: opts.maxOutput,
|
||||
@@ -477,59 +328,9 @@ export async function runExecProcess(opts: {
|
||||
exitSignal: undefined as NodeJS.Signals | number | null | undefined,
|
||||
truncated: false,
|
||||
backgrounded: false,
|
||||
} satisfies ProcessSession;
|
||||
};
|
||||
addSession(session);
|
||||
|
||||
let settled = false;
|
||||
let timeoutTimer: NodeJS.Timeout | null = null;
|
||||
let timeoutFinalizeTimer: NodeJS.Timeout | null = null;
|
||||
let timedOut = false;
|
||||
const timeoutFinalizeMs = 1000;
|
||||
let resolveFn: ((outcome: ExecProcessOutcome) => void) | null = null;
|
||||
|
||||
const settle = (outcome: ExecProcessOutcome) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolveFn?.(outcome);
|
||||
};
|
||||
|
||||
const finalizeTimeout = () => {
|
||||
if (session.exited) {
|
||||
return;
|
||||
}
|
||||
markExited(session, null, "SIGKILL", "failed");
|
||||
maybeNotifyOnExit(session, "failed");
|
||||
const aggregated = session.aggregated.trim();
|
||||
const reason = `Command timed out after ${opts.timeoutSec} seconds`;
|
||||
settle({
|
||||
status: "failed",
|
||||
exitCode: null,
|
||||
exitSignal: "SIGKILL",
|
||||
durationMs: Date.now() - startedAt,
|
||||
aggregated,
|
||||
timedOut: true,
|
||||
reason: aggregated ? `${aggregated}\n\n${reason}` : reason,
|
||||
});
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
timedOut = true;
|
||||
killSession(session);
|
||||
if (!timeoutFinalizeTimer) {
|
||||
timeoutFinalizeTimer = setTimeout(() => {
|
||||
finalizeTimeout();
|
||||
}, timeoutFinalizeMs);
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.timeoutSec > 0) {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
onTimeout();
|
||||
}, opts.timeoutSec * 1000);
|
||||
}
|
||||
|
||||
const emitUpdate = () => {
|
||||
if (!opts.onUpdate) {
|
||||
return;
|
||||
@@ -565,116 +366,208 @@ export async function runExecProcess(opts: {
|
||||
}
|
||||
};
|
||||
|
||||
if (pty) {
|
||||
const cursorResponse = buildCursorPositionResponse();
|
||||
pty.onData((data) => {
|
||||
const raw = data.toString();
|
||||
const { cleaned, requests } = stripDsrRequests(raw);
|
||||
if (requests > 0) {
|
||||
const timeoutMs =
|
||||
typeof opts.timeoutSec === "number" && opts.timeoutSec > 0
|
||||
? Math.floor(opts.timeoutSec * 1000)
|
||||
: undefined;
|
||||
|
||||
const spawnSpec:
|
||||
| {
|
||||
mode: "child";
|
||||
argv: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdinMode: "pipe-open" | "pipe-closed";
|
||||
}
|
||||
| {
|
||||
mode: "pty";
|
||||
ptyCommand: string;
|
||||
childFallbackArgv: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdinMode: "pipe-open";
|
||||
} = (() => {
|
||||
if (opts.sandbox) {
|
||||
return {
|
||||
mode: "child" as const,
|
||||
argv: [
|
||||
"docker",
|
||||
...buildDockerExecArgs({
|
||||
containerName: opts.sandbox.containerName,
|
||||
command: execCommand,
|
||||
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
||||
env: opts.env,
|
||||
tty: opts.usePty,
|
||||
}),
|
||||
],
|
||||
env: process.env,
|
||||
stdinMode: opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const),
|
||||
};
|
||||
}
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
const childArgv = [shell, ...shellArgs, execCommand];
|
||||
if (opts.usePty) {
|
||||
return {
|
||||
mode: "pty" as const,
|
||||
ptyCommand: execCommand,
|
||||
childFallbackArgv: childArgv,
|
||||
env: opts.env,
|
||||
stdinMode: "pipe-open" as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: "child" as const,
|
||||
argv: childArgv,
|
||||
env: opts.env,
|
||||
stdinMode: "pipe-closed" as const,
|
||||
};
|
||||
})();
|
||||
|
||||
let managedRun: ManagedRun | null = null;
|
||||
let usingPty = spawnSpec.mode === "pty";
|
||||
const cursorResponse = buildCursorPositionResponse();
|
||||
|
||||
const onSupervisorStdout = (chunk: string) => {
|
||||
if (usingPty) {
|
||||
const { cleaned, requests } = stripDsrRequests(chunk);
|
||||
if (requests > 0 && managedRun?.stdin) {
|
||||
for (let i = 0; i < requests; i += 1) {
|
||||
pty.write(cursorResponse);
|
||||
managedRun.stdin.write(cursorResponse);
|
||||
}
|
||||
}
|
||||
handleStdout(cleaned);
|
||||
});
|
||||
} else if (child) {
|
||||
child.stdout.on("data", handleStdout);
|
||||
child.stderr.on("data", handleStderr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleStdout(chunk);
|
||||
};
|
||||
|
||||
const promise = new Promise<ExecProcessOutcome>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
if (timeoutFinalizeTimer) {
|
||||
clearTimeout(timeoutFinalizeTimer);
|
||||
try {
|
||||
const spawnBase = {
|
||||
runId: sessionId,
|
||||
sessionId: opts.sessionKey?.trim() || sessionId,
|
||||
backendId: opts.sandbox ? "exec-sandbox" : "exec-host",
|
||||
scopeKey: opts.scopeKey,
|
||||
cwd: opts.workdir,
|
||||
env: spawnSpec.env,
|
||||
timeoutMs,
|
||||
captureOutput: false,
|
||||
onStdout: onSupervisorStdout,
|
||||
onStderr: handleStderr,
|
||||
};
|
||||
managedRun =
|
||||
spawnSpec.mode === "pty"
|
||||
? await supervisor.spawn({
|
||||
...spawnBase,
|
||||
mode: "pty",
|
||||
ptyCommand: spawnSpec.ptyCommand,
|
||||
})
|
||||
: await supervisor.spawn({
|
||||
...spawnBase,
|
||||
mode: "child",
|
||||
argv: spawnSpec.argv,
|
||||
stdinMode: spawnSpec.stdinMode,
|
||||
});
|
||||
} catch (err) {
|
||||
if (spawnSpec.mode === "pty") {
|
||||
const warning = `Warning: PTY spawn failed (${String(err)}); retrying without PTY for \`${opts.command}\`.`;
|
||||
logWarn(
|
||||
`exec: PTY spawn failed (${String(err)}); retrying without PTY for "${opts.command}".`,
|
||||
);
|
||||
opts.warnings.push(warning);
|
||||
usingPty = false;
|
||||
try {
|
||||
managedRun = await supervisor.spawn({
|
||||
runId: sessionId,
|
||||
sessionId: opts.sessionKey?.trim() || sessionId,
|
||||
backendId: "exec-host",
|
||||
scopeKey: opts.scopeKey,
|
||||
mode: "child",
|
||||
argv: spawnSpec.childFallbackArgv,
|
||||
cwd: opts.workdir,
|
||||
env: spawnSpec.env,
|
||||
stdinMode: "pipe-open",
|
||||
timeoutMs,
|
||||
captureOutput: false,
|
||||
onStdout: handleStdout,
|
||||
onStderr: handleStderr,
|
||||
});
|
||||
} catch (retryErr) {
|
||||
markExited(session, null, null, "failed");
|
||||
maybeNotifyOnExit(session, "failed");
|
||||
throw retryErr;
|
||||
}
|
||||
} else {
|
||||
markExited(session, null, null, "failed");
|
||||
maybeNotifyOnExit(session, "failed");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
session.stdin = managedRun.stdin;
|
||||
session.pid = managedRun.pid;
|
||||
|
||||
const promise = managedRun
|
||||
.wait()
|
||||
.then((exit): ExecProcessOutcome => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const wasSignal = exitSignal != null;
|
||||
const isSuccess = code === 0 && !wasSignal && !timedOut;
|
||||
const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
|
||||
markExited(session, code, exitSignal, status);
|
||||
const status: "completed" | "failed" =
|
||||
exit.exitCode === 0 && exit.reason === "exit" ? "completed" : "failed";
|
||||
markExited(session, exit.exitCode, exit.exitSignal, status);
|
||||
maybeNotifyOnExit(session, status);
|
||||
if (!session.child && session.stdin) {
|
||||
session.stdin.destroyed = true;
|
||||
}
|
||||
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const aggregated = session.aggregated.trim();
|
||||
if (!isSuccess) {
|
||||
const reason = timedOut
|
||||
? `Command timed out after ${opts.timeoutSec} seconds`
|
||||
: wasSignal && exitSignal
|
||||
? `Command aborted by signal ${exitSignal}`
|
||||
: code === null
|
||||
? "Command aborted before exit code was captured"
|
||||
: `Command exited with code ${code}`;
|
||||
const message = aggregated ? `${aggregated}\n\n${reason}` : reason;
|
||||
settle({
|
||||
status: "failed",
|
||||
exitCode: code ?? null,
|
||||
exitSignal: exitSignal ?? null,
|
||||
if (status === "completed") {
|
||||
return {
|
||||
status: "completed",
|
||||
exitCode: exit.exitCode ?? 0,
|
||||
exitSignal: exit.exitSignal,
|
||||
durationMs,
|
||||
aggregated,
|
||||
timedOut,
|
||||
reason: message,
|
||||
});
|
||||
return;
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
settle({
|
||||
status: "completed",
|
||||
exitCode: code ?? 0,
|
||||
exitSignal: exitSignal ?? null,
|
||||
const reason =
|
||||
exit.reason === "overall-timeout"
|
||||
? `Command timed out after ${opts.timeoutSec} seconds`
|
||||
: exit.reason === "no-output-timeout"
|
||||
? "Command timed out waiting for output"
|
||||
: exit.exitSignal != null
|
||||
? `Command aborted by signal ${exit.exitSignal}`
|
||||
: exit.exitCode == null
|
||||
? "Command aborted before exit code was captured"
|
||||
: `Command exited with code ${exit.exitCode}`;
|
||||
return {
|
||||
status: "failed",
|
||||
exitCode: exit.exitCode,
|
||||
exitSignal: exit.exitSignal,
|
||||
durationMs,
|
||||
aggregated,
|
||||
timedOut: exit.timedOut,
|
||||
reason: aggregated ? `${aggregated}\n\n${reason}` : reason,
|
||||
};
|
||||
})
|
||||
.catch((err): ExecProcessOutcome => {
|
||||
markExited(session, null, null, "failed");
|
||||
maybeNotifyOnExit(session, "failed");
|
||||
const aggregated = session.aggregated.trim();
|
||||
const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err);
|
||||
return {
|
||||
status: "failed",
|
||||
exitCode: null,
|
||||
exitSignal: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
aggregated,
|
||||
timedOut: false,
|
||||
});
|
||||
};
|
||||
|
||||
if (pty) {
|
||||
pty.onExit((event) => {
|
||||
const rawSignal = event.signal ?? null;
|
||||
const normalizedSignal = rawSignal === 0 ? null : rawSignal;
|
||||
handleExit(event.exitCode ?? null, normalizedSignal);
|
||||
});
|
||||
} else if (child) {
|
||||
child.once("close", (code, exitSignal) => {
|
||||
handleExit(code, exitSignal);
|
||||
});
|
||||
|
||||
child.once("error", (err) => {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
if (timeoutFinalizeTimer) {
|
||||
clearTimeout(timeoutFinalizeTimer);
|
||||
}
|
||||
markExited(session, null, null, "failed");
|
||||
maybeNotifyOnExit(session, "failed");
|
||||
const aggregated = session.aggregated.trim();
|
||||
const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err);
|
||||
settle({
|
||||
status: "failed",
|
||||
exitCode: null,
|
||||
exitSignal: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
aggregated,
|
||||
timedOut,
|
||||
reason: message,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
reason: message,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
startedAt,
|
||||
pid: session.pid ?? undefined,
|
||||
promise,
|
||||
kill: () => killSession(session),
|
||||
kill: () => {
|
||||
managedRun?.cancel("manual-cancel");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
73
src/agents/bash-tools.exec.pty-cleanup.test.ts
Normal file
73
src/agents/bash-tools.exec.pty-cleanup.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("exec disposes PTY listeners after normal exit", async () => {
|
||||
const disposeData = vi.fn();
|
||||
const disposeExit = vi.fn();
|
||||
|
||||
vi.doMock("@lydell/node-pty", () => ({
|
||||
spawn: () => {
|
||||
return {
|
||||
pid: 0,
|
||||
write: vi.fn(),
|
||||
onData: (listener: (value: string) => void) => {
|
||||
setTimeout(() => listener("ok"), 0);
|
||||
return { dispose: disposeData };
|
||||
},
|
||||
onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
|
||||
setTimeout(() => listener({ exitCode: 0 }), 0);
|
||||
return { dispose: disposeExit };
|
||||
},
|
||||
kill: vi.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec");
|
||||
const tool = createExecTool({ allowBackground: false });
|
||||
const result = await tool.execute("toolcall", {
|
||||
command: "echo ok",
|
||||
pty: true,
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(disposeData).toHaveBeenCalledTimes(1);
|
||||
expect(disposeExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("exec tears down PTY resources on timeout", async () => {
|
||||
const disposeData = vi.fn();
|
||||
const disposeExit = vi.fn();
|
||||
const kill = vi.fn();
|
||||
|
||||
vi.doMock("@lydell/node-pty", () => ({
|
||||
spawn: () => {
|
||||
return {
|
||||
pid: 0,
|
||||
write: vi.fn(),
|
||||
onData: () => ({ dispose: disposeData }),
|
||||
onExit: () => ({ dispose: disposeExit }),
|
||||
kill,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec");
|
||||
const tool = createExecTool({ allowBackground: false });
|
||||
await expect(
|
||||
tool.execute("toolcall", {
|
||||
command: "sleep 5",
|
||||
pty: true,
|
||||
timeout: 0.01,
|
||||
}),
|
||||
).rejects.toThrow("Command timed out");
|
||||
expect(kill).toHaveBeenCalledTimes(1);
|
||||
expect(disposeData).toHaveBeenCalledTimes(1);
|
||||
expect(disposeExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
40
src/agents/bash-tools.exec.pty-fallback-failure.test.ts
Normal file
40
src/agents/bash-tools.exec.pty-fallback-failure.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { listRunningSessions, resetProcessRegistryForTests } from "./bash-process-registry";
|
||||
|
||||
const { supervisorSpawnMock } = vi.hoisted(() => ({
|
||||
supervisorSpawnMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => ({
|
||||
getProcessSupervisor: () => ({
|
||||
spawn: (...args: unknown[]) => supervisorSpawnMock(...args),
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("exec cleans session state when PTY fallback spawn also fails", async () => {
|
||||
supervisorSpawnMock
|
||||
.mockRejectedValueOnce(new Error("pty spawn failed"))
|
||||
.mockRejectedValueOnce(new Error("child fallback failed"));
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec");
|
||||
const tool = createExecTool({ allowBackground: false });
|
||||
|
||||
await expect(
|
||||
tool.execute("toolcall", {
|
||||
command: "echo ok",
|
||||
pty: true,
|
||||
}),
|
||||
).rejects.toThrow("child fallback failed");
|
||||
|
||||
expect(listRunningSessions()).toHaveLength(0);
|
||||
});
|
||||
152
src/agents/bash-tools.process.supervisor.test.ts
Normal file
152
src/agents/bash-tools.process.supervisor.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import {
|
||||
addSession,
|
||||
getFinishedSession,
|
||||
getSession,
|
||||
resetProcessRegistryForTests,
|
||||
} from "./bash-process-registry.js";
|
||||
import { createProcessTool } from "./bash-tools.process.js";
|
||||
|
||||
const { supervisorMock } = vi.hoisted(() => ({
|
||||
supervisorMock: {
|
||||
spawn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { killProcessTreeMock } = vi.hoisted(() => ({
|
||||
killProcessTreeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => ({
|
||||
getProcessSupervisor: () => supervisorMock,
|
||||
}));
|
||||
|
||||
vi.mock("../process/kill-tree.js", () => ({
|
||||
killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args),
|
||||
}));
|
||||
|
||||
function createBackgroundSession(id: string, pid?: number): ProcessSession {
|
||||
return {
|
||||
id,
|
||||
command: "sleep 999",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 10_000,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
pid,
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("process tool supervisor cancellation", () => {
|
||||
beforeEach(() => {
|
||||
supervisorMock.spawn.mockReset();
|
||||
supervisorMock.cancel.mockReset();
|
||||
supervisorMock.cancelScope.mockReset();
|
||||
supervisorMock.reconcileOrphans.mockReset();
|
||||
supervisorMock.getRecord.mockReset();
|
||||
killProcessTreeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
});
|
||||
|
||||
it("routes kill through supervisor when run is managed", async () => {
|
||||
supervisorMock.getRecord.mockReturnValue({
|
||||
runId: "sess",
|
||||
state: "running",
|
||||
});
|
||||
addSession(createBackgroundSession("sess"));
|
||||
const processTool = createProcessTool();
|
||||
|
||||
const result = await processTool.execute("toolcall", {
|
||||
action: "kill",
|
||||
sessionId: "sess",
|
||||
});
|
||||
|
||||
expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel");
|
||||
expect(getSession("sess")).toBeDefined();
|
||||
expect(getSession("sess")?.exited).toBe(false);
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Termination requested for session sess.",
|
||||
});
|
||||
});
|
||||
|
||||
it("remove drops running session immediately when cancellation is requested", async () => {
|
||||
supervisorMock.getRecord.mockReturnValue({
|
||||
runId: "sess",
|
||||
state: "running",
|
||||
});
|
||||
addSession(createBackgroundSession("sess"));
|
||||
const processTool = createProcessTool();
|
||||
|
||||
const result = await processTool.execute("toolcall", {
|
||||
action: "remove",
|
||||
sessionId: "sess",
|
||||
});
|
||||
|
||||
expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel");
|
||||
expect(getSession("sess")).toBeUndefined();
|
||||
expect(getFinishedSession("sess")).toBeUndefined();
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Removed session sess (termination requested).",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to process-tree kill when supervisor record is missing", async () => {
|
||||
supervisorMock.getRecord.mockReturnValue(undefined);
|
||||
addSession(createBackgroundSession("sess-fallback", 4242));
|
||||
const processTool = createProcessTool();
|
||||
|
||||
const result = await processTool.execute("toolcall", {
|
||||
action: "kill",
|
||||
sessionId: "sess-fallback",
|
||||
});
|
||||
|
||||
expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
|
||||
expect(getSession("sess-fallback")).toBeUndefined();
|
||||
expect(getFinishedSession("sess-fallback")).toBeDefined();
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Killed session sess-fallback.",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails remove when no supervisor record and no pid is available", async () => {
|
||||
supervisorMock.getRecord.mockReturnValue(undefined);
|
||||
addSession(createBackgroundSession("sess-no-pid"));
|
||||
const processTool = createProcessTool();
|
||||
|
||||
const result = await processTool.execute("toolcall", {
|
||||
action: "remove",
|
||||
sessionId: "sess-no-pid",
|
||||
});
|
||||
|
||||
expect(killProcessTreeMock).not.toHaveBeenCalled();
|
||||
expect(getSession("sess-no-pid")).toBeDefined();
|
||||
expect(result.details).toMatchObject({ status: "failed" });
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Unable to remove session sess-no-pid: no active supervisor run or process id.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { formatDurationCompact } from "../infra/format-time/format-duration.ts";
|
||||
import { killProcessTree } from "../process/kill-tree.js";
|
||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||
import {
|
||||
type ProcessSession,
|
||||
deleteSession,
|
||||
drainSession,
|
||||
getFinishedSession,
|
||||
@@ -11,13 +14,7 @@ import {
|
||||
markExited,
|
||||
setJobTtlMs,
|
||||
} from "./bash-process-registry.js";
|
||||
import {
|
||||
deriveSessionName,
|
||||
killSession,
|
||||
pad,
|
||||
sliceLogLines,
|
||||
truncateMiddle,
|
||||
} from "./bash-tools.shared.js";
|
||||
import { deriveSessionName, pad, sliceLogLines, truncateMiddle } from "./bash-tools.shared.js";
|
||||
import { encodeKeySequence, encodePaste } from "./pty-keys.js";
|
||||
|
||||
export type ProcessToolDefaults = {
|
||||
@@ -65,8 +62,9 @@ const processSchema = Type.Object({
|
||||
offset: Type.Optional(Type.Number({ description: "Log offset" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Log length" })),
|
||||
timeout: Type.Optional(
|
||||
Type.Union([Type.Number(), Type.String()], {
|
||||
Type.Number({
|
||||
description: "For poll: wait up to this many milliseconds before returning",
|
||||
minimum: 0,
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -106,9 +104,28 @@ export function createProcessTool(
|
||||
setJobTtlMs(defaults.cleanupMs);
|
||||
}
|
||||
const scopeKey = defaults?.scopeKey;
|
||||
const supervisor = getProcessSupervisor();
|
||||
const isInScope = (session?: { scopeKey?: string } | null) =>
|
||||
!scopeKey || session?.scopeKey === scopeKey;
|
||||
|
||||
const cancelManagedSession = (sessionId: string) => {
|
||||
const record = supervisor.getRecord(sessionId);
|
||||
if (!record || record.state === "exited") {
|
||||
return false;
|
||||
}
|
||||
supervisor.cancel(sessionId, "manual-cancel");
|
||||
return true;
|
||||
};
|
||||
|
||||
const terminateSessionFallback = (session: ProcessSession) => {
|
||||
const pid = session.pid ?? session.child?.pid;
|
||||
if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
killProcessTree(pid);
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
name: "process",
|
||||
label: "process",
|
||||
@@ -138,7 +155,7 @@ export function createProcessTool(
|
||||
eof?: boolean;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
timeout?: number | string;
|
||||
timeout?: unknown;
|
||||
};
|
||||
|
||||
if (params.action === "list") {
|
||||
@@ -522,10 +539,25 @@ export function createProcessTool(
|
||||
if (!scopedSession.backgrounded) {
|
||||
return failText(`Session ${params.sessionId} is not backgrounded.`);
|
||||
}
|
||||
killSession(scopedSession);
|
||||
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||
const canceled = cancelManagedSession(scopedSession.id);
|
||||
if (!canceled) {
|
||||
const terminated = terminateSessionFallback(scopedSession);
|
||||
if (!terminated) {
|
||||
return failText(
|
||||
`Unable to terminate session ${params.sessionId}: no active supervisor run or process id.`,
|
||||
);
|
||||
}
|
||||
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Killed session ${params.sessionId}.` }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: canceled
|
||||
? `Termination requested for session ${params.sessionId}.`
|
||||
: `Killed session ${params.sessionId}.`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "failed",
|
||||
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
|
||||
@@ -554,10 +586,30 @@ export function createProcessTool(
|
||||
|
||||
case "remove": {
|
||||
if (scopedSession) {
|
||||
killSession(scopedSession);
|
||||
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||
const canceled = cancelManagedSession(scopedSession.id);
|
||||
if (canceled) {
|
||||
// Keep remove semantics deterministic: drop from process registry now.
|
||||
scopedSession.backgrounded = false;
|
||||
deleteSession(params.sessionId);
|
||||
} else {
|
||||
const terminated = terminateSessionFallback(scopedSession);
|
||||
if (!terminated) {
|
||||
return failText(
|
||||
`Unable to remove session ${params.sessionId}: no active supervisor run or process id.`,
|
||||
);
|
||||
}
|
||||
markExited(scopedSession, null, "SIGKILL", "failed");
|
||||
deleteSession(params.sessionId);
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Removed session ${params.sessionId}.` }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: canceled
|
||||
? `Removed session ${params.sessionId} (termination requested).`
|
||||
: `Removed session ${params.sessionId}.`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "failed",
|
||||
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { sliceUtf16Safe } from "../utils.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import { killProcessTree } from "./shell-utils.js";
|
||||
|
||||
const CHUNK_LIMIT = 8 * 1024;
|
||||
|
||||
@@ -115,13 +113,6 @@ export async function resolveSandboxWorkdir(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export function killSession(session: { pid?: number; child?: ChildProcessWithoutNullStreams }) {
|
||||
const pid = session.pid ?? session.child?.pid;
|
||||
if (pid) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorkdir(workdir: string, warnings: string[]) {
|
||||
const current = safeCwd();
|
||||
const fallback = current ?? homedir();
|
||||
|
||||
36
src/agents/cli-backends.test.ts
Normal file
36
src/agents/cli-backends.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveCliBackendConfig } from "./cli-backends.js";
|
||||
|
||||
describe("resolveCliBackendConfig reliability merge", () => {
|
||||
it("deep-merges reliability watchdog overrides for codex", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
cliBackends: {
|
||||
"codex-cli": {
|
||||
command: "codex",
|
||||
reliability: {
|
||||
watchdog: {
|
||||
resume: {
|
||||
noOutputTimeoutMs: 42_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveCliBackendConfig("codex-cli", cfg);
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutMs).toBe(42_000);
|
||||
// Ensure defaults are retained when only one field is overridden.
|
||||
expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutRatio).toBe(0.3);
|
||||
expect(resolved?.config.reliability?.watchdog?.resume?.minMs).toBe(60_000);
|
||||
expect(resolved?.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000);
|
||||
expect(resolved?.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { CliBackendConfig } from "../config/types.js";
|
||||
import {
|
||||
CLI_FRESH_WATCHDOG_DEFAULTS,
|
||||
CLI_RESUME_WATCHDOG_DEFAULTS,
|
||||
} from "./cli-watchdog-defaults.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
export type ResolvedCliBackend = {
|
||||
@@ -49,6 +53,12 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
|
||||
systemPromptMode: "append",
|
||||
systemPromptWhen: "first",
|
||||
clearEnv: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"],
|
||||
reliability: {
|
||||
watchdog: {
|
||||
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },
|
||||
resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS },
|
||||
},
|
||||
},
|
||||
serialize: true,
|
||||
};
|
||||
|
||||
@@ -73,6 +83,12 @@ const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
|
||||
sessionMode: "existing",
|
||||
imageArg: "--image",
|
||||
imageMode: "repeat",
|
||||
reliability: {
|
||||
watchdog: {
|
||||
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },
|
||||
resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS },
|
||||
},
|
||||
},
|
||||
serialize: true,
|
||||
};
|
||||
|
||||
@@ -96,6 +112,10 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig)
|
||||
if (!override) {
|
||||
return { ...base };
|
||||
}
|
||||
const baseFresh = base.reliability?.watchdog?.fresh ?? {};
|
||||
const baseResume = base.reliability?.watchdog?.resume ?? {};
|
||||
const overrideFresh = override.reliability?.watchdog?.fresh ?? {};
|
||||
const overrideResume = override.reliability?.watchdog?.resume ?? {};
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
@@ -106,6 +126,22 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig)
|
||||
sessionIdFields: override.sessionIdFields ?? base.sessionIdFields,
|
||||
sessionArgs: override.sessionArgs ?? base.sessionArgs,
|
||||
resumeArgs: override.resumeArgs ?? base.resumeArgs,
|
||||
reliability: {
|
||||
...base.reliability,
|
||||
...override.reliability,
|
||||
watchdog: {
|
||||
...base.reliability?.watchdog,
|
||||
...override.reliability?.watchdog,
|
||||
fresh: {
|
||||
...baseFresh,
|
||||
...overrideFresh,
|
||||
},
|
||||
resume: {
|
||||
...baseResume,
|
||||
...overrideResume,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,50 +3,69 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { CliBackendConfig } from "../config/types.js";
|
||||
import { runCliAgent } from "./cli-runner.js";
|
||||
import { cleanupResumeProcesses, cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js";
|
||||
import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.fn();
|
||||
const runExecMock = vi.fn();
|
||||
const supervisorSpawnMock = vi.fn();
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
runExec: (...args: unknown[]) => runExecMock(...args),
|
||||
vi.mock("../process/supervisor/index.js", () => ({
|
||||
getProcessSupervisor: () => ({
|
||||
spawn: (...args: unknown[]) => supervisorSpawnMock(...args),
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("runCliAgent resume cleanup", () => {
|
||||
type MockRunExit = {
|
||||
reason:
|
||||
| "manual-cancel"
|
||||
| "overall-timeout"
|
||||
| "no-output-timeout"
|
||||
| "spawn-error"
|
||||
| "signal"
|
||||
| "exit";
|
||||
exitCode: number | null;
|
||||
exitSignal: NodeJS.Signals | number | null;
|
||||
durationMs: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
noOutputTimedOut: boolean;
|
||||
};
|
||||
|
||||
function createManagedRun(exit: MockRunExit, pid = 1234) {
|
||||
return {
|
||||
runId: "run-supervisor",
|
||||
pid,
|
||||
startedAtMs: Date.now(),
|
||||
stdin: undefined,
|
||||
wait: vi.fn().mockResolvedValue(exit),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCliAgent with process supervisor", () => {
|
||||
beforeEach(() => {
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
runExecMock.mockReset();
|
||||
supervisorSpawnMock.mockReset();
|
||||
});
|
||||
|
||||
it("kills stale resume processes for codex sessions", async () => {
|
||||
const selfPid = process.pid;
|
||||
|
||||
runExecMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: " 1 999 S /bin/launchd\n",
|
||||
it("runs CLI through supervisor and returns payload", async () => {
|
||||
supervisorSpawnMock.mockResolvedValueOnce(
|
||||
createManagedRun({
|
||||
reason: "exit",
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 50,
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
}) // cleanupSuspendedCliProcesses (ps) — ppid 999 != selfPid, no match
|
||||
.mockResolvedValueOnce({
|
||||
stdout: [
|
||||
` ${selfPid + 1} ${selfPid} codex exec resume thread-123 --color never --sandbox read-only --skip-git-repo-check`,
|
||||
` ${selfPid + 2} 999 codex exec resume thread-123 --color never --sandbox read-only --skip-git-repo-check`,
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
}) // cleanupResumeProcesses (ps)
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" }) // cleanupResumeProcesses (kill -TERM)
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (kill -9)
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await runCliAgent({
|
||||
const result = await runCliAgent({
|
||||
sessionId: "s1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
@@ -58,28 +77,80 @@ describe("runCliAgent resume cleanup", () => {
|
||||
cliSessionId: "thread-123",
|
||||
});
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
expect(result.payloads?.[0]?.text).toBe("ok");
|
||||
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
|
||||
const input = supervisorSpawnMock.mock.calls[0]?.[0] as {
|
||||
argv?: string[];
|
||||
mode?: string;
|
||||
timeoutMs?: number;
|
||||
noOutputTimeoutMs?: number;
|
||||
replaceExistingScope?: boolean;
|
||||
scopeKey?: string;
|
||||
};
|
||||
expect(input.mode).toBe("child");
|
||||
expect(input.argv?.[0]).toBe("codex");
|
||||
expect(input.timeoutMs).toBe(1_000);
|
||||
expect(input.noOutputTimeoutMs).toBeGreaterThanOrEqual(1_000);
|
||||
expect(input.replaceExistingScope).toBe(true);
|
||||
expect(input.scopeKey).toContain("thread-123");
|
||||
});
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(4);
|
||||
it("fails with timeout when no-output watchdog trips", async () => {
|
||||
supervisorSpawnMock.mockResolvedValueOnce(
|
||||
createManagedRun({
|
||||
reason: "no-output-timeout",
|
||||
exitCode: null,
|
||||
exitSignal: "SIGKILL",
|
||||
durationMs: 200,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: true,
|
||||
noOutputTimedOut: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Second call: cleanupResumeProcesses ps
|
||||
const psCall = runExecMock.mock.calls[1] ?? [];
|
||||
expect(psCall[0]).toBe("ps");
|
||||
await expect(
|
||||
runCliAgent({
|
||||
sessionId: "s1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
prompt: "hi",
|
||||
provider: "codex-cli",
|
||||
model: "gpt-5.2-codex",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-2",
|
||||
cliSessionId: "thread-123",
|
||||
}),
|
||||
).rejects.toThrow("produced no output");
|
||||
});
|
||||
|
||||
// Third call: TERM, only the child PID
|
||||
const termCall = runExecMock.mock.calls[2] ?? [];
|
||||
expect(termCall[0]).toBe("kill");
|
||||
const termArgs = termCall[1] as string[];
|
||||
expect(termArgs).toEqual(["-TERM", String(selfPid + 1)]);
|
||||
it("fails with timeout when overall timeout trips", async () => {
|
||||
supervisorSpawnMock.mockResolvedValueOnce(
|
||||
createManagedRun({
|
||||
reason: "overall-timeout",
|
||||
exitCode: null,
|
||||
exitSignal: "SIGKILL",
|
||||
durationMs: 200,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: true,
|
||||
noOutputTimedOut: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fourth call: KILL, only the child PID
|
||||
const killCall = runExecMock.mock.calls[3] ?? [];
|
||||
expect(killCall[0]).toBe("kill");
|
||||
const killArgs = killCall[1] as string[];
|
||||
expect(killArgs).toEqual(["-9", String(selfPid + 1)]);
|
||||
await expect(
|
||||
runCliAgent({
|
||||
sessionId: "s1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
prompt: "hi",
|
||||
provider: "codex-cli",
|
||||
model: "gpt-5.2-codex",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-3",
|
||||
cliSessionId: "thread-123",
|
||||
}),
|
||||
).rejects.toThrow("exceeded timeout");
|
||||
});
|
||||
|
||||
it("falls back to per-agent workspace when workspaceDir is missing", async () => {
|
||||
@@ -94,14 +165,18 @@ describe("runCliAgent resume cleanup", () => {
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
supervisorSpawnMock.mockResolvedValueOnce(
|
||||
createManagedRun({
|
||||
reason: "exit",
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 25,
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await runCliAgent({
|
||||
@@ -114,264 +189,33 @@ describe("runCliAgent resume cleanup", () => {
|
||||
provider: "codex-cli",
|
||||
model: "gpt-5.2-codex",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-1",
|
||||
runId: "run-4",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const options = runCommandWithTimeoutMock.mock.calls[0]?.[1] as { cwd?: string };
|
||||
expect(options.cwd).toBe(path.resolve(fallbackWorkspace));
|
||||
const input = supervisorSpawnMock.mock.calls[0]?.[0] as { cwd?: string };
|
||||
expect(input.cwd).toBe(path.resolve(fallbackWorkspace));
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when sessionKey is malformed", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-runner-"));
|
||||
const mainWorkspace = path.join(tempDir, "workspace-main");
|
||||
const researchWorkspace = path.join(tempDir, "workspace-research");
|
||||
await fs.mkdir(mainWorkspace, { recursive: true });
|
||||
await fs.mkdir(researchWorkspace, { recursive: true });
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: mainWorkspace,
|
||||
describe("resolveCliNoOutputTimeoutMs", () => {
|
||||
it("uses backend-configured resume watchdog override", () => {
|
||||
const timeoutMs = resolveCliNoOutputTimeoutMs({
|
||||
backend: {
|
||||
command: "codex",
|
||||
reliability: {
|
||||
watchdog: {
|
||||
resume: {
|
||||
noOutputTimeoutMs: 42_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
list: [{ id: "research", workspace: researchWorkspace }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runCliAgent({
|
||||
sessionId: "s1",
|
||||
sessionKey: "agent::broken",
|
||||
agentId: "research",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: undefined as unknown as string,
|
||||
config: cfg,
|
||||
prompt: "hi",
|
||||
provider: "codex-cli",
|
||||
model: "gpt-5.2-codex",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-2",
|
||||
}),
|
||||
).rejects.toThrow("Malformed agent session key");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupSuspendedCliProcesses", () => {
|
||||
beforeEach(() => {
|
||||
runExecMock.mockReset();
|
||||
});
|
||||
|
||||
it("skips when no session tokens are configured", async () => {
|
||||
await cleanupSuspendedCliProcesses(
|
||||
{
|
||||
command: "tool",
|
||||
} as CliBackendConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches sessionArg-based commands", async () => {
|
||||
const selfPid = process.pid;
|
||||
runExecMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: [
|
||||
` 40 ${selfPid} T+ claude --session-id thread-1 -p`,
|
||||
` 41 ${selfPid} S claude --session-id thread-2 -p`,
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
|
||||
await cleanupSuspendedCliProcesses(
|
||||
{
|
||||
command: "claude",
|
||||
sessionArg: "--session-id",
|
||||
} as CliBackendConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
||||
const killCall = runExecMock.mock.calls[1] ?? [];
|
||||
expect(killCall[0]).toBe("kill");
|
||||
expect(killCall[1]).toEqual(["-9", "40"]);
|
||||
});
|
||||
|
||||
it("matches resumeArgs with positional session id", async () => {
|
||||
const selfPid = process.pid;
|
||||
runExecMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: [
|
||||
` 50 ${selfPid} T codex exec resume thread-99 --color never --sandbox read-only`,
|
||||
` 51 ${selfPid} T codex exec resume other --color never --sandbox read-only`,
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
|
||||
await cleanupSuspendedCliProcesses(
|
||||
{
|
||||
command: "codex",
|
||||
resumeArgs: ["exec", "resume", "{sessionId}", "--color", "never", "--sandbox", "read-only"],
|
||||
} as CliBackendConfig,
|
||||
1,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
||||
const killCall = runExecMock.mock.calls[1] ?? [];
|
||||
expect(killCall[0]).toBe("kill");
|
||||
expect(killCall[1]).toEqual(["-9", "50", "51"]);
|
||||
});
|
||||
|
||||
it("only kills child processes of current process (ppid validation)", async () => {
|
||||
const selfPid = process.pid;
|
||||
const childPid = selfPid + 1;
|
||||
const unrelatedPid = 9999;
|
||||
|
||||
runExecMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: [
|
||||
` ${childPid} ${selfPid} T claude --session-id thread-1 -p`,
|
||||
` ${unrelatedPid} 100 T claude --session-id thread-2 -p`,
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
|
||||
await cleanupSuspendedCliProcesses(
|
||||
{
|
||||
command: "claude",
|
||||
sessionArg: "--session-id",
|
||||
} as CliBackendConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
||||
const killCall = runExecMock.mock.calls[1] ?? [];
|
||||
expect(killCall[0]).toBe("kill");
|
||||
// Only childPid killed; unrelatedPid (ppid=100) excluded
|
||||
expect(killCall[1]).toEqual(["-9", String(childPid)]);
|
||||
});
|
||||
|
||||
it("skips all processes when none are children of current process", async () => {
|
||||
runExecMock.mockResolvedValueOnce({
|
||||
stdout: [
|
||||
" 200 100 T claude --session-id thread-1 -p",
|
||||
" 201 100 T claude --session-id thread-2 -p",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
timeoutMs: 120_000,
|
||||
useResume: true,
|
||||
});
|
||||
|
||||
await cleanupSuspendedCliProcesses(
|
||||
{
|
||||
command: "claude",
|
||||
sessionArg: "--session-id",
|
||||
} as CliBackendConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only ps called — no kill because no matching ppid
|
||||
expect(runExecMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupResumeProcesses", () => {
|
||||
beforeEach(() => {
|
||||
runExecMock.mockReset();
|
||||
});
|
||||
|
||||
it("only kills resume processes owned by current process", async () => {
|
||||
const selfPid = process.pid;
|
||||
|
||||
runExecMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: [
|
||||
` ${selfPid + 1} ${selfPid} codex exec resume abc-123`,
|
||||
` ${selfPid + 2} 999 codex exec resume abc-123`,
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" })
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
|
||||
await cleanupResumeProcesses(
|
||||
{
|
||||
command: "codex",
|
||||
resumeArgs: ["exec", "resume", "{sessionId}"],
|
||||
} as CliBackendConfig,
|
||||
"abc-123",
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const termCall = runExecMock.mock.calls[1] ?? [];
|
||||
expect(termCall[0]).toBe("kill");
|
||||
expect(termCall[1]).toEqual(["-TERM", String(selfPid + 1)]);
|
||||
|
||||
const killCall = runExecMock.mock.calls[2] ?? [];
|
||||
expect(killCall[0]).toBe("kill");
|
||||
expect(killCall[1]).toEqual(["-9", String(selfPid + 1)]);
|
||||
});
|
||||
|
||||
it("skips kill when no resume processes match ppid", async () => {
|
||||
runExecMock.mockResolvedValueOnce({
|
||||
stdout: [" 300 100 codex exec resume abc-123", " 301 200 codex exec resume abc-123"].join(
|
||||
"\n",
|
||||
),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await cleanupResumeProcesses(
|
||||
{
|
||||
command: "codex",
|
||||
resumeArgs: ["exec", "resume", "{sessionId}"],
|
||||
} as CliBackendConfig,
|
||||
"abc-123",
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only ps called — no kill because no matching ppid
|
||||
expect(runExecMock).toHaveBeenCalledTimes(1);
|
||||
expect(timeoutMs).toBe(42_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,20 +6,20 @@ import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
||||
import { shouldLogVerbose } from "../globals.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
||||
import { resolveCliBackendConfig } from "./cli-backends.js";
|
||||
import {
|
||||
appendImagePathsToPrompt,
|
||||
buildCliSupervisorScopeKey,
|
||||
buildCliArgs,
|
||||
buildSystemPrompt,
|
||||
cleanupResumeProcesses,
|
||||
cleanupSuspendedCliProcesses,
|
||||
enqueueCliRun,
|
||||
normalizeCliModel,
|
||||
parseCliJson,
|
||||
parseCliJsonl,
|
||||
resolveCliNoOutputTimeoutMs,
|
||||
resolvePromptInput,
|
||||
resolveSessionIdToSend,
|
||||
resolveSystemPromptUsage,
|
||||
@@ -226,19 +226,32 @@ export async function runCliAgent(params: {
|
||||
}
|
||||
return next;
|
||||
})();
|
||||
|
||||
// Cleanup suspended processes that have accumulated (regardless of sessionId)
|
||||
await cleanupSuspendedCliProcesses(backend);
|
||||
if (useResume && cliSessionIdToSend) {
|
||||
await cleanupResumeProcesses(backend, cliSessionIdToSend);
|
||||
}
|
||||
|
||||
const result = await runCommandWithTimeout([backend.command, ...args], {
|
||||
const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({
|
||||
backend,
|
||||
timeoutMs: params.timeoutMs,
|
||||
useResume,
|
||||
});
|
||||
const supervisor = getProcessSupervisor();
|
||||
const scopeKey = buildCliSupervisorScopeKey({
|
||||
backend,
|
||||
backendId: backendResolved.id,
|
||||
cliSessionId: useResume ? cliSessionIdToSend : undefined,
|
||||
});
|
||||
|
||||
const managedRun = await supervisor.spawn({
|
||||
sessionId: params.sessionId,
|
||||
backendId: backendResolved.id,
|
||||
scopeKey,
|
||||
replaceExistingScope: Boolean(useResume && scopeKey),
|
||||
mode: "child",
|
||||
argv: [backend.command, ...args],
|
||||
timeoutMs: params.timeoutMs,
|
||||
noOutputTimeoutMs,
|
||||
cwd: workspaceDir,
|
||||
env,
|
||||
input: stdinPayload,
|
||||
});
|
||||
const result = await managedRun.wait();
|
||||
|
||||
const stdout = result.stdout.trim();
|
||||
const stderr = result.stderr.trim();
|
||||
@@ -259,7 +272,28 @@ export async function runCliAgent(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
if (result.exitCode !== 0 || result.reason !== "exit") {
|
||||
if (result.reason === "no-output-timeout" || result.noOutputTimedOut) {
|
||||
const timeoutReason = `CLI produced no output for ${Math.round(noOutputTimeoutMs / 1000)}s and was terminated.`;
|
||||
log.warn(
|
||||
`cli watchdog timeout: provider=${params.provider} model=${modelId} session=${cliSessionIdToSend ?? params.sessionId} noOutputTimeoutMs=${noOutputTimeoutMs} pid=${managedRun.pid ?? "unknown"}`,
|
||||
);
|
||||
throw new FailoverError(timeoutReason, {
|
||||
reason: "timeout",
|
||||
provider: params.provider,
|
||||
model: modelId,
|
||||
status: resolveFailoverStatus("timeout"),
|
||||
});
|
||||
}
|
||||
if (result.reason === "overall-timeout") {
|
||||
const timeoutReason = `CLI exceeded timeout (${Math.round(params.timeoutMs / 1000)}s) and was terminated.`;
|
||||
throw new FailoverError(timeoutReason, {
|
||||
reason: "timeout",
|
||||
provider: params.provider,
|
||||
model: modelId,
|
||||
status: resolveFailoverStatus("timeout"),
|
||||
});
|
||||
}
|
||||
const err = stderr || stdout || "CLI failed.";
|
||||
const reason = classifyFailoverReason(err) ?? "unknown";
|
||||
const status = resolveFailoverStatus(reason);
|
||||
|
||||
@@ -8,232 +8,27 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
||||
import { escapeRegExp, isRecord } from "../../utils.js";
|
||||
import { isRecord } from "../../utils.js";
|
||||
import { buildModelAliasLines } from "../model-alias-lines.js";
|
||||
import { resolveDefaultModelForAgent } from "../model-selection.js";
|
||||
import { detectRuntimeShell } from "../shell-utils.js";
|
||||
import { buildSystemPromptParams } from "../system-prompt-params.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
export { buildCliSupervisorScopeKey, resolveCliNoOutputTimeoutMs } from "./reliability.js";
|
||||
|
||||
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
||||
|
||||
function buildLooseArgOrderRegex(tokens: string[]): RegExp {
|
||||
// Scan `ps` output lines. Keep matching flexible, but require whitespace arg boundaries
|
||||
// to avoid substring matches like `codexx` or `/path/to/codexx`.
|
||||
const [head, ...rest] = tokens.map((t) => String(t ?? "").trim()).filter(Boolean);
|
||||
if (!head) {
|
||||
return /$^/;
|
||||
}
|
||||
|
||||
const headEscaped = escapeRegExp(head);
|
||||
const headFragment = `(?:^|\\s)(?:${headEscaped}|\\S+\\/${headEscaped})(?=\\s|$)`;
|
||||
const restFragments = rest.map((t) => `(?:^|\\s)${escapeRegExp(t)}(?=\\s|$)`);
|
||||
return new RegExp([headFragment, ...restFragments].join(".*"));
|
||||
}
|
||||
|
||||
async function psWithFallback(argsA: string[], argsB: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await runExec("ps", argsA);
|
||||
return stdout;
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
const { stdout } = await runExec("ps", argsB);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export async function cleanupResumeProcesses(
|
||||
backend: CliBackendConfig,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const resumeArgs = backend.resumeArgs ?? [];
|
||||
if (resumeArgs.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!resumeArgs.some((arg) => arg.includes("{sessionId}"))) {
|
||||
return;
|
||||
}
|
||||
const commandToken = path.basename(backend.command ?? "").trim();
|
||||
if (!commandToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resumeTokens = resumeArgs.map((arg) => arg.replaceAll("{sessionId}", sessionId));
|
||||
const pattern = [commandToken, ...resumeTokens]
|
||||
.filter(Boolean)
|
||||
.map((token) => escapeRegExp(token))
|
||||
.join(".*");
|
||||
if (!pattern) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = await psWithFallback(
|
||||
["-axww", "-o", "pid=,ppid=,command="],
|
||||
["-ax", "-o", "pid=,ppid=,command="],
|
||||
);
|
||||
const patternRegex = buildLooseArgOrderRegex([commandToken, ...resumeTokens]);
|
||||
const toKill: number[] = [];
|
||||
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const ppid = Number(match[2]);
|
||||
const cmd = match[3] ?? "";
|
||||
if (!Number.isFinite(pid)) {
|
||||
continue;
|
||||
}
|
||||
if (ppid !== process.pid) {
|
||||
continue;
|
||||
}
|
||||
if (!patternRegex.test(cmd)) {
|
||||
continue;
|
||||
}
|
||||
toKill.push(pid);
|
||||
}
|
||||
|
||||
if (toKill.length > 0) {
|
||||
const pidArgs = toKill.map((pid) => String(pid));
|
||||
try {
|
||||
await runExec("kill", ["-TERM", ...pidArgs]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
try {
|
||||
await runExec("kill", ["-9", ...pidArgs]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore errors - best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionMatchers(backend: CliBackendConfig): RegExp[] {
|
||||
const commandToken = path.basename(backend.command ?? "").trim();
|
||||
if (!commandToken) {
|
||||
return [];
|
||||
}
|
||||
const matchers: RegExp[] = [];
|
||||
const sessionArg = backend.sessionArg?.trim();
|
||||
const sessionArgs = backend.sessionArgs ?? [];
|
||||
const resumeArgs = backend.resumeArgs ?? [];
|
||||
|
||||
const addMatcher = (args: string[]) => {
|
||||
if (args.length === 0) {
|
||||
return;
|
||||
}
|
||||
const tokens = [commandToken, ...args];
|
||||
const pattern = tokens
|
||||
.map((token, index) => {
|
||||
const tokenPattern = tokenToRegex(token);
|
||||
return index === 0 ? `(?:^|\\s)${tokenPattern}` : `\\s+${tokenPattern}`;
|
||||
})
|
||||
.join("");
|
||||
matchers.push(new RegExp(pattern));
|
||||
};
|
||||
|
||||
if (sessionArgs.some((arg) => arg.includes("{sessionId}"))) {
|
||||
addMatcher(sessionArgs);
|
||||
} else if (sessionArg) {
|
||||
addMatcher([sessionArg, "{sessionId}"]);
|
||||
}
|
||||
|
||||
if (resumeArgs.some((arg) => arg.includes("{sessionId}"))) {
|
||||
addMatcher(resumeArgs);
|
||||
}
|
||||
|
||||
return matchers;
|
||||
}
|
||||
|
||||
function tokenToRegex(token: string): string {
|
||||
if (!token.includes("{sessionId}")) {
|
||||
return escapeRegExp(token);
|
||||
}
|
||||
const parts = token.split("{sessionId}").map((part) => escapeRegExp(part));
|
||||
return parts.join("\\S+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup suspended OpenClaw CLI processes that have accumulated.
|
||||
* Only cleans up if there are more than the threshold (default: 10).
|
||||
*/
|
||||
export async function cleanupSuspendedCliProcesses(
|
||||
backend: CliBackendConfig,
|
||||
threshold = 10,
|
||||
): Promise<void> {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const matchers = buildSessionMatchers(backend);
|
||||
if (matchers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = await psWithFallback(
|
||||
["-axww", "-o", "pid=,ppid=,stat=,command="],
|
||||
["-ax", "-o", "pid=,ppid=,stat=,command="],
|
||||
);
|
||||
const suspended: number[] = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const match = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const ppid = Number(match[2]);
|
||||
const stat = match[3] ?? "";
|
||||
const command = match[4] ?? "";
|
||||
if (!Number.isFinite(pid)) {
|
||||
continue;
|
||||
}
|
||||
if (ppid !== process.pid) {
|
||||
continue;
|
||||
}
|
||||
if (!stat.includes("T")) {
|
||||
continue;
|
||||
}
|
||||
if (!matchers.some((matcher) => matcher.test(command))) {
|
||||
continue;
|
||||
}
|
||||
suspended.push(pid);
|
||||
}
|
||||
|
||||
if (suspended.length > threshold) {
|
||||
// Verified locally: stopped (T) processes ignore SIGTERM, so use SIGKILL.
|
||||
await runExec("kill", ["-9", ...suspended.map((pid) => String(pid))]);
|
||||
}
|
||||
} catch {
|
||||
// ignore errors - best effort cleanup
|
||||
}
|
||||
}
|
||||
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
|
||||
const chained = prior.catch(() => undefined).then(task);
|
||||
const tracked = chained.finally(() => {
|
||||
if (CLI_RUN_QUEUE.get(key) === tracked) {
|
||||
CLI_RUN_QUEUE.delete(key);
|
||||
}
|
||||
});
|
||||
// Keep queue continuity even when a run rejects, without emitting unhandled rejections.
|
||||
const tracked = chained
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (CLI_RUN_QUEUE.get(key) === tracked) {
|
||||
CLI_RUN_QUEUE.delete(key);
|
||||
}
|
||||
});
|
||||
CLI_RUN_QUEUE.set(key, tracked);
|
||||
return chained;
|
||||
}
|
||||
|
||||
88
src/agents/cli-runner/reliability.ts
Normal file
88
src/agents/cli-runner/reliability.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import path from "node:path";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import {
|
||||
CLI_FRESH_WATCHDOG_DEFAULTS,
|
||||
CLI_RESUME_WATCHDOG_DEFAULTS,
|
||||
CLI_WATCHDOG_MIN_TIMEOUT_MS,
|
||||
} from "../cli-watchdog-defaults.js";
|
||||
|
||||
function pickWatchdogProfile(
|
||||
backend: CliBackendConfig,
|
||||
useResume: boolean,
|
||||
): {
|
||||
noOutputTimeoutMs?: number;
|
||||
noOutputTimeoutRatio: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
} {
|
||||
const defaults = useResume ? CLI_RESUME_WATCHDOG_DEFAULTS : CLI_FRESH_WATCHDOG_DEFAULTS;
|
||||
const configured = useResume
|
||||
? backend.reliability?.watchdog?.resume
|
||||
: backend.reliability?.watchdog?.fresh;
|
||||
|
||||
const ratio = (() => {
|
||||
const value = configured?.noOutputTimeoutRatio;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return defaults.noOutputTimeoutRatio;
|
||||
}
|
||||
return Math.max(0.05, Math.min(0.95, value));
|
||||
})();
|
||||
const minMs = (() => {
|
||||
const value = configured?.minMs;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return defaults.minMs;
|
||||
}
|
||||
return Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(value));
|
||||
})();
|
||||
const maxMs = (() => {
|
||||
const value = configured?.maxMs;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return defaults.maxMs;
|
||||
}
|
||||
return Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(value));
|
||||
})();
|
||||
|
||||
return {
|
||||
noOutputTimeoutMs:
|
||||
typeof configured?.noOutputTimeoutMs === "number" &&
|
||||
Number.isFinite(configured.noOutputTimeoutMs)
|
||||
? Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(configured.noOutputTimeoutMs))
|
||||
: undefined,
|
||||
noOutputTimeoutRatio: ratio,
|
||||
minMs: Math.min(minMs, maxMs),
|
||||
maxMs: Math.max(minMs, maxMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCliNoOutputTimeoutMs(params: {
|
||||
backend: CliBackendConfig;
|
||||
timeoutMs: number;
|
||||
useResume: boolean;
|
||||
}): number {
|
||||
const profile = pickWatchdogProfile(params.backend, params.useResume);
|
||||
// Keep watchdog below global timeout in normal cases.
|
||||
const cap = Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, params.timeoutMs - 1_000);
|
||||
if (profile.noOutputTimeoutMs !== undefined) {
|
||||
return Math.min(profile.noOutputTimeoutMs, cap);
|
||||
}
|
||||
const computed = Math.floor(params.timeoutMs * profile.noOutputTimeoutRatio);
|
||||
const bounded = Math.min(profile.maxMs, Math.max(profile.minMs, computed));
|
||||
return Math.min(bounded, cap);
|
||||
}
|
||||
|
||||
export function buildCliSupervisorScopeKey(params: {
|
||||
backend: CliBackendConfig;
|
||||
backendId: string;
|
||||
cliSessionId?: string;
|
||||
}): string | undefined {
|
||||
const commandToken = path
|
||||
.basename(params.backend.command ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const backendToken = params.backendId.trim().toLowerCase();
|
||||
const sessionToken = params.cliSessionId?.trim();
|
||||
if (!sessionToken) {
|
||||
return undefined;
|
||||
}
|
||||
return `cli:${backendToken}:${commandToken}:${sessionToken}`;
|
||||
}
|
||||
13
src/agents/cli-watchdog-defaults.ts
Normal file
13
src/agents/cli-watchdog-defaults.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const CLI_WATCHDOG_MIN_TIMEOUT_MS = 1_000;
|
||||
|
||||
export const CLI_FRESH_WATCHDOG_DEFAULTS = {
|
||||
noOutputTimeoutRatio: 0.8,
|
||||
minMs: 180_000,
|
||||
maxMs: 600_000,
|
||||
} as const;
|
||||
|
||||
export const CLI_RESUME_WATCHDOG_DEFAULTS = {
|
||||
noOutputTimeoutRatio: 0.3,
|
||||
minMs: 60_000,
|
||||
maxMs: 180_000,
|
||||
} as const;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyConfiguredContextWindows } from "./context.js";
|
||||
import { createSessionManagerRuntimeRegistry } from "./pi-extensions/session-manager-runtime-registry.js";
|
||||
|
||||
describe("applyConfiguredContextWindows", () => {
|
||||
it("overrides discovered cache values with explicit models.providers contextWindow", () => {
|
||||
@@ -39,3 +40,23 @@ describe("applyConfiguredContextWindows", () => {
|
||||
expect(cache.has("bad/model")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSessionManagerRuntimeRegistry", () => {
|
||||
it("stores, reads, and clears values by object identity", () => {
|
||||
const registry = createSessionManagerRuntimeRegistry<{ value: number }>();
|
||||
const key = {};
|
||||
expect(registry.get(key)).toBeNull();
|
||||
registry.set(key, { value: 1 });
|
||||
expect(registry.get(key)).toEqual({ value: 1 });
|
||||
registry.set(key, null);
|
||||
expect(registry.get(key)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores non-object keys", () => {
|
||||
const registry = createSessionManagerRuntimeRegistry<{ value: number }>();
|
||||
registry.set(null, { value: 1 });
|
||||
registry.set(123, { value: 1 });
|
||||
expect(registry.get(null)).toBeNull();
|
||||
expect(registry.get(123)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
|
||||
|
||||
@@ -15,9 +16,11 @@ const oauthFixture = {
|
||||
|
||||
describe("getApiKeyForModel", () => {
|
||||
it("migrates legacy oauth.json into auth-profiles.json", async () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_AGENT_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
]);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-"));
|
||||
|
||||
try {
|
||||
@@ -73,30 +76,18 @@ describe("getApiKeyForModel", () => {
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("suggests openai-codex when only Codex OAuth is configured", async () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENAI_API_KEY",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_AGENT_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
]);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
|
||||
try {
|
||||
@@ -137,26 +128,7 @@ describe("getApiKeyForModel", () => {
|
||||
}
|
||||
expect(String(error)).toContain("openai-codex/gpt-5.3-codex");
|
||||
} finally {
|
||||
if (previousOpenAiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
||||
}
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { scanOpenRouterModels } from "./model-scan.js";
|
||||
|
||||
function createFetchFixture(payload: unknown): typeof fetch {
|
||||
@@ -66,7 +67,7 @@ describe("scanOpenRouterModels", () => {
|
||||
|
||||
it("requires an API key when probing", async () => {
|
||||
const fetchImpl = createFetchFixture({ data: [] });
|
||||
const previousKey = process.env.OPENROUTER_API_KEY;
|
||||
const envSnapshot = captureEnv(["OPENROUTER_API_KEY"]);
|
||||
try {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
await expect(
|
||||
@@ -77,11 +78,7 @@ describe("scanOpenRouterModels", () => {
|
||||
}),
|
||||
).rejects.toThrow(/Missing OpenRouter API key/);
|
||||
} finally {
|
||||
if (previousKey === undefined) {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
} else {
|
||||
process.env.OPENROUTER_API_KEY = previousKey;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
installModelsConfigTestHooks,
|
||||
withModelsTempHome as withTempHome,
|
||||
@@ -12,7 +13,7 @@ installModelsConfigTestHooks({ restoreFetch: true });
|
||||
describe("models-config", () => {
|
||||
it("auto-injects github-copilot provider when token is present", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]);
|
||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -36,20 +37,14 @@ describe("models-config", () => {
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
|
||||
await withTempHome(async () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
const previousGithub = process.env.GITHUB_TOKEN;
|
||||
const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]);
|
||||
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
|
||||
process.env.GH_TOKEN = "gh-token";
|
||||
process.env.GITHUB_TOKEN = "github-token";
|
||||
@@ -70,9 +65,7 @@ describe("models-config", () => {
|
||||
const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record<string, string> }];
|
||||
expect(opts?.headers?.Authorization).toBe("Bearer copilot-token");
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
process.env.GH_TOKEN = previousGh;
|
||||
process.env.GITHUB_TOKEN = previousGithub;
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
installModelsConfigTestHooks,
|
||||
withModelsTempHome as withTempHome,
|
||||
@@ -13,7 +14,7 @@ installModelsConfigTestHooks({ restoreFetch: true });
|
||||
describe("models-config", () => {
|
||||
it("falls back to default baseUrl when token exchange fails", async () => {
|
||||
await withTempHome(async () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]);
|
||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -33,20 +34,14 @@ describe("models-config", () => {
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_COPILOT_API_BASE_URL);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("uses agentDir override auth profiles for copilot injection", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
const previousGithub = process.env.GITHUB_TOKEN;
|
||||
const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]);
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
@@ -91,21 +86,7 @@ describe("models-config", () => {
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
if (previousGh === undefined) {
|
||||
delete process.env.GH_TOKEN;
|
||||
} else {
|
||||
process.env.GH_TOKEN = previousGh;
|
||||
}
|
||||
if (previousGithub === undefined) {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.GITHUB_TOKEN = previousGithub;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
|
||||
describe("MiniMax implicit provider (#15275)", () => {
|
||||
it("should use anthropic-messages API for API-key provider", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const previous = process.env.MINIMAX_API_KEY;
|
||||
const envSnapshot = captureEnv(["MINIMAX_API_KEY"]);
|
||||
process.env.MINIMAX_API_KEY = "test-key";
|
||||
|
||||
try {
|
||||
@@ -16,11 +17,7 @@ describe("MiniMax implicit provider (#15275)", () => {
|
||||
expect(providers?.minimax?.api).toBe("anthropic-messages");
|
||||
expect(providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = previous;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,14 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveApiKeyForProvider } from "./model-auth.js";
|
||||
import { buildNvidiaProvider, resolveImplicitProviders } from "./models-config.providers.js";
|
||||
|
||||
describe("NVIDIA provider", () => {
|
||||
it("should include nvidia when NVIDIA_API_KEY is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const previous = process.env.NVIDIA_API_KEY;
|
||||
const envSnapshot = captureEnv(["NVIDIA_API_KEY"]);
|
||||
process.env.NVIDIA_API_KEY = "test-key";
|
||||
|
||||
try {
|
||||
@@ -16,17 +17,13 @@ describe("NVIDIA provider", () => {
|
||||
expect(providers?.nvidia).toBeDefined();
|
||||
expect(providers?.nvidia?.models?.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.NVIDIA_API_KEY;
|
||||
} else {
|
||||
process.env.NVIDIA_API_KEY = previous;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the nvidia api key value from env", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const previous = process.env.NVIDIA_API_KEY;
|
||||
const envSnapshot = captureEnv(["NVIDIA_API_KEY"]);
|
||||
process.env.NVIDIA_API_KEY = "nvidia-test-api-key";
|
||||
|
||||
try {
|
||||
@@ -39,11 +36,7 @@ describe("NVIDIA provider", () => {
|
||||
expect(auth.mode).toBe("api-key");
|
||||
expect(auth.source).toContain("NVIDIA_API_KEY");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.NVIDIA_API_KEY;
|
||||
} else {
|
||||
process.env.NVIDIA_API_KEY = previous;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
|
||||
describe("Qianfan provider", () => {
|
||||
it("should include qianfan when QIANFAN_API_KEY is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const previous = process.env.QIANFAN_API_KEY;
|
||||
const envSnapshot = captureEnv(["QIANFAN_API_KEY"]);
|
||||
process.env.QIANFAN_API_KEY = "test-key";
|
||||
|
||||
try {
|
||||
@@ -15,11 +16,7 @@ describe("Qianfan provider", () => {
|
||||
expect(providers?.qianfan).toBeDefined();
|
||||
expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.QIANFAN_API_KEY;
|
||||
} else {
|
||||
process.env.QIANFAN_API_KEY = previous;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
installModelsConfigTestHooks,
|
||||
@@ -13,9 +14,7 @@ installModelsConfigTestHooks({ restoreFetch: true });
|
||||
describe("models-config", () => {
|
||||
it("uses the first github-copilot profile when env tokens are missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
const previousGithub = process.env.GITHUB_TOKEN;
|
||||
const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]);
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
@@ -61,28 +60,14 @@ describe("models-config", () => {
|
||||
const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record<string, string> }];
|
||||
expect(opts?.headers?.Authorization).toBe("Bearer alpha-token");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
if (previousGh === undefined) {
|
||||
delete process.env.GH_TOKEN;
|
||||
} else {
|
||||
process.env.GH_TOKEN = previousGh;
|
||||
}
|
||||
if (previousGithub === undefined) {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.GITHUB_TOKEN = previousGithub;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override explicit github-copilot provider config", async () => {
|
||||
await withTempHome(async () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]);
|
||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -115,11 +100,7 @@ describe("models-config", () => {
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://copilot.local");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
@@ -18,8 +19,7 @@ describe("gateway tool", () => {
|
||||
it("schedules SIGUSR1 restart", async () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousProfile = process.env.OPENCLAW_PROFILE;
|
||||
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_PROFILE"]);
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.OPENCLAW_PROFILE = "isolated";
|
||||
@@ -60,16 +60,8 @@ describe("gateway tool", () => {
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
vi.useRealTimers();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousProfile === undefined) {
|
||||
delete process.env.OPENCLAW_PROFILE;
|
||||
} else {
|
||||
process.env.OPENCLAW_PROFILE = previousProfile;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -783,7 +783,7 @@ describe("sessions tools", () => {
|
||||
text?: string;
|
||||
};
|
||||
expect(details.status).toBe("ok");
|
||||
expect(details.text).toContain("tokens 1k (in 12 / out 1k)");
|
||||
expect(details.text).toMatch(/tokens 1(\.0)?k \(in 12 \/ out 1(\.0)?k\)/);
|
||||
expect(details.text).toContain("prompt/cache 197k");
|
||||
expect(details.text).not.toContain("1.0k io");
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
import {
|
||||
getCallGatewayMock,
|
||||
resetSessionsSpawnConfigOverride,
|
||||
@@ -9,7 +8,19 @@ import {
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
const callGatewayMock = getCallGatewayMock();
|
||||
const setConfigOverride = setSessionsSpawnConfigOverride;
|
||||
|
||||
type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
|
||||
type CreateOpenClawToolsOpts = Parameters<CreateOpenClawTools>[0];
|
||||
|
||||
async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
|
||||
// Dynamic import: ensure harness mocks are installed before tool modules load.
|
||||
const { createOpenClawTools } = await import("./openclaw-tools.js");
|
||||
const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
beforeEach(() => {
|
||||
@@ -20,13 +31,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call6", {
|
||||
task: "do thing",
|
||||
@@ -58,13 +66,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call9", {
|
||||
task: "do thing",
|
||||
@@ -79,7 +84,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
it("sessions_spawn allows cross-agent spawning when configured", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
setConfigOverride({
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
@@ -110,13 +115,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call7", {
|
||||
task: "do thing",
|
||||
@@ -133,7 +135,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
it("sessions_spawn allows any agent when allowlist is *", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
setConfigOverride({
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
@@ -164,13 +166,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call8", {
|
||||
task: "do thing",
|
||||
@@ -187,7 +186,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
it("sessions_spawn normalizes allowlisted agent ids", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
setConfigOverride({
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
@@ -218,13 +217,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call10", {
|
||||
task: "do thing",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
import { sleep } from "../utils.js";
|
||||
import {
|
||||
getCallGatewayMock,
|
||||
resetSessionsSpawnConfigOverride,
|
||||
@@ -17,6 +17,19 @@ vi.mock("./pi-embedded.js", () => ({
|
||||
|
||||
const callGatewayMock = getCallGatewayMock();
|
||||
|
||||
type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
|
||||
type CreateOpenClawToolsOpts = Parameters<CreateOpenClawTools>[0];
|
||||
|
||||
async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
|
||||
// Dynamic import: ensure harness mocks are installed before tool modules load.
|
||||
const { createOpenClawTools } = await import("./openclaw-tools.js");
|
||||
const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
type GatewayRequest = { method?: string; params?: unknown };
|
||||
type AgentWaitCall = { runId?: string; timeoutMs?: number };
|
||||
|
||||
@@ -112,6 +125,16 @@ function setupSessionsSpawnGatewayMock(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for condition (timeoutMs=${timeoutMs})`);
|
||||
}
|
||||
await sleep(10);
|
||||
}
|
||||
};
|
||||
|
||||
describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
beforeEach(() => {
|
||||
resetSessionsSpawnConfigOverride();
|
||||
@@ -120,26 +143,21 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
it("sessions_spawn runs cleanup flow after subagent completion", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
let patchParams: { key?: string; label?: string } = {};
|
||||
const patchCalls: Array<{ key?: string; label?: string }> = [];
|
||||
|
||||
const ctx = setupSessionsSpawnGatewayMock({
|
||||
includeSessionsList: true,
|
||||
includeChatHistory: true,
|
||||
onSessionsPatch: (params) => {
|
||||
const rec = params as { key?: string; label?: string } | undefined;
|
||||
if (typeof rec?.label === "string" && rec.label.trim()) {
|
||||
patchParams = { key: rec.key, label: rec.label };
|
||||
}
|
||||
patchCalls.push({ key: rec?.key, label: rec?.label });
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call2", {
|
||||
task: "do thing",
|
||||
@@ -165,18 +183,16 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId));
|
||||
await waitFor(() => patchCalls.some((call) => call.label === "my-task"));
|
||||
await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2);
|
||||
|
||||
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
|
||||
expect(childWait?.timeoutMs).toBe(1000);
|
||||
// Cleanup should patch the label
|
||||
expect(patchParams.key).toBe(child.sessionKey);
|
||||
expect(patchParams.label).toBe("my-task");
|
||||
const labelPatch = patchCalls.find((call) => call.label === "my-task");
|
||||
expect(labelPatch?.key).toBe(child.sessionKey);
|
||||
expect(labelPatch?.label).toBe("my-task");
|
||||
|
||||
// Two agent calls: subagent spawn + main agent trigger
|
||||
const agentCalls = ctx.calls.filter((c) => c.method === "agent");
|
||||
@@ -213,13 +229,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
task: "do thing",
|
||||
@@ -307,13 +320,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 },
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1b", {
|
||||
task: "do thing",
|
||||
@@ -325,14 +335,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
const child = ctx.getChild();
|
||||
if (!child.runId) {
|
||||
throw new Error("missing child runId");
|
||||
}
|
||||
await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId));
|
||||
await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2);
|
||||
await waitFor(() => Boolean(deletedKey));
|
||||
|
||||
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
|
||||
expect(childWait?.timeoutMs).toBe(1000);
|
||||
expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
@@ -397,13 +407,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-timeout", {
|
||||
task: "do thing",
|
||||
@@ -415,12 +422,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await waitFor(() => calls.filter((call) => call.method === "agent").length >= 2);
|
||||
|
||||
const mainAgentCall = calls
|
||||
.filter((call) => call.method === "agent")
|
||||
@@ -472,14 +474,11 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
agentAccountId: "kev",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-announce-account", {
|
||||
task: "do thing",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
import {
|
||||
getCallGatewayMock,
|
||||
resetSessionsSpawnConfigOverride,
|
||||
@@ -11,6 +10,19 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
const callGatewayMock = getCallGatewayMock();
|
||||
|
||||
type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
|
||||
type CreateOpenClawToolsOpts = Parameters<CreateOpenClawTools>[0];
|
||||
|
||||
async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
|
||||
// Dynamic import: ensure harness mocks are installed before tool modules load.
|
||||
const { createOpenClawTools } = await import("./openclaw-tools.js");
|
||||
const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
beforeEach(() => {
|
||||
resetSessionsSpawnConfigOverride();
|
||||
@@ -46,13 +58,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call3", {
|
||||
task: "do thing",
|
||||
@@ -93,13 +102,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-thinking", {
|
||||
task: "do thing",
|
||||
@@ -126,13 +132,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-thinking-invalid", {
|
||||
task: "do thing",
|
||||
@@ -166,13 +169,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-default-model", {
|
||||
task: "do thing",
|
||||
@@ -207,13 +207,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-runtime-default-model", {
|
||||
task: "do thing",
|
||||
@@ -255,13 +252,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "agent:research:main",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-agent-model", {
|
||||
task: "do thing",
|
||||
@@ -289,8 +283,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "sessions.patch") {
|
||||
const params = request.params as { model?: unknown } | undefined;
|
||||
if (typeof params?.model === "string" && params.model.trim()) {
|
||||
const model = (request.params as { model?: unknown } | undefined)?.model;
|
||||
if (model === "bad-model") {
|
||||
throw new Error("invalid model: bad-model");
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -313,13 +307,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call4", {
|
||||
task: "do thing",
|
||||
@@ -351,13 +342,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute("call5", {
|
||||
task: "do thing",
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-registry.js";
|
||||
|
||||
describe("createSessionManagerRuntimeRegistry", () => {
|
||||
it("stores, reads, and clears values by object identity", () => {
|
||||
const registry = createSessionManagerRuntimeRegistry<{ value: number }>();
|
||||
const key = {};
|
||||
expect(registry.get(key)).toBeNull();
|
||||
registry.set(key, { value: 1 });
|
||||
expect(registry.get(key)).toEqual({ value: 1 });
|
||||
registry.set(key, null);
|
||||
expect(registry.get(key)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores non-object keys", () => {
|
||||
const registry = createSessionManagerRuntimeRegistry<{ value: number }>();
|
||||
registry.set(null, { value: 1 });
|
||||
registry.set(123, { value: 1 });
|
||||
expect(registry.get(null)).toBeNull();
|
||||
expect(registry.get(123)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,9 @@ import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ExecApprovalsResolved } from "../infra/exec-approvals.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
|
||||
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
const bundledPluginsDirSnapshot = captureEnv(["OPENCLAW_BUNDLED_PLUGINS_DIR"]);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(
|
||||
@@ -15,32 +16,18 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (previousBundledPluginsDir === undefined) {
|
||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir;
|
||||
}
|
||||
bundledPluginsDirSnapshot.restore();
|
||||
});
|
||||
|
||||
vi.mock("../infra/shell-env.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("../infra/shell-env.js")>();
|
||||
return {
|
||||
...mod,
|
||||
getShellPathFromLoginShell: vi.fn(() => "/usr/bin:/bin"),
|
||||
getShellPathFromLoginShell: vi.fn(() => null),
|
||||
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 500),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/tools.js", () => ({
|
||||
getPluginToolMeta: () => undefined,
|
||||
resolvePluginTools: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../infra/shell-env.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("../infra/shell-env.js")>();
|
||||
return { ...mod, getShellPathFromLoginShell: () => null };
|
||||
});
|
||||
|
||||
vi.mock("../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
getPluginToolMeta: () => undefined,
|
||||
@@ -109,20 +96,16 @@ describe("createOpenClawCodingTools safeBins", () => {
|
||||
expect(execTool).toBeDefined();
|
||||
|
||||
const marker = `safe-bins-${Date.now()}`;
|
||||
const prevShellEnvTimeoutMs = process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS;
|
||||
process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000";
|
||||
const envSnapshot = captureEnv(["OPENCLAW_SHELL_ENV_TIMEOUT_MS"]);
|
||||
const result = await (async () => {
|
||||
try {
|
||||
process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000";
|
||||
return await execTool!.execute("call1", {
|
||||
command: `echo ${marker}`,
|
||||
workdir: tmpDir,
|
||||
});
|
||||
} finally {
|
||||
if (prevShellEnvTimeoutMs === undefined) {
|
||||
delete process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = prevShellEnvTimeoutMs;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
}
|
||||
})();
|
||||
const text = result.content.find((content) => content.type === "text")?.text ?? "";
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("buildSandboxCreateArgs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("emits -v flags for custom binds", () => {
|
||||
it("emits -v flags for safe custom binds", () => {
|
||||
const cfg: SandboxDockerConfig = {
|
||||
image: "openclaw-sandbox:bookworm-slim",
|
||||
containerPrefix: "openclaw-sbx-",
|
||||
@@ -103,7 +103,7 @@ describe("buildSandboxCreateArgs", () => {
|
||||
tmpfs: [],
|
||||
network: "none",
|
||||
capDrop: [],
|
||||
binds: ["/home/user/source:/source:rw", "/var/run/docker.sock:/var/run/docker.sock"],
|
||||
binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"],
|
||||
};
|
||||
|
||||
const args = buildSandboxCreateArgs({
|
||||
@@ -124,7 +124,116 @@ describe("buildSandboxCreateArgs", () => {
|
||||
}
|
||||
}
|
||||
expect(vFlags).toContain("/home/user/source:/source:rw");
|
||||
expect(vFlags).toContain("/var/run/docker.sock:/var/run/docker.sock");
|
||||
expect(vFlags).toContain("/var/data/myapp:/data:ro");
|
||||
});
|
||||
|
||||
it("throws on dangerous bind mounts (Docker socket)", () => {
|
||||
const cfg: SandboxDockerConfig = {
|
||||
image: "openclaw-sandbox:bookworm-slim",
|
||||
containerPrefix: "openclaw-sbx-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: false,
|
||||
tmpfs: [],
|
||||
network: "none",
|
||||
capDrop: [],
|
||||
binds: ["/var/run/docker.sock:/var/run/docker.sock"],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
buildSandboxCreateArgs({
|
||||
name: "openclaw-sbx-dangerous",
|
||||
cfg,
|
||||
scopeKey: "main",
|
||||
createdAtMs: 1700000000000,
|
||||
}),
|
||||
).toThrow(/blocked path/);
|
||||
});
|
||||
|
||||
it("throws on dangerous bind mounts (parent path)", () => {
|
||||
const cfg: SandboxDockerConfig = {
|
||||
image: "openclaw-sandbox:bookworm-slim",
|
||||
containerPrefix: "openclaw-sbx-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: false,
|
||||
tmpfs: [],
|
||||
network: "none",
|
||||
capDrop: [],
|
||||
binds: ["/run:/run"],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
buildSandboxCreateArgs({
|
||||
name: "openclaw-sbx-dangerous-parent",
|
||||
cfg,
|
||||
scopeKey: "main",
|
||||
createdAtMs: 1700000000000,
|
||||
}),
|
||||
).toThrow(/blocked path/);
|
||||
});
|
||||
|
||||
it("throws on network host mode", () => {
|
||||
const cfg: SandboxDockerConfig = {
|
||||
image: "openclaw-sandbox:bookworm-slim",
|
||||
containerPrefix: "openclaw-sbx-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: false,
|
||||
tmpfs: [],
|
||||
network: "host",
|
||||
capDrop: [],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
buildSandboxCreateArgs({
|
||||
name: "openclaw-sbx-host",
|
||||
cfg,
|
||||
scopeKey: "main",
|
||||
createdAtMs: 1700000000000,
|
||||
}),
|
||||
).toThrow(/network mode "host" is blocked/);
|
||||
});
|
||||
|
||||
it("throws on seccomp unconfined", () => {
|
||||
const cfg: SandboxDockerConfig = {
|
||||
image: "openclaw-sandbox:bookworm-slim",
|
||||
containerPrefix: "openclaw-sbx-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: false,
|
||||
tmpfs: [],
|
||||
network: "none",
|
||||
capDrop: [],
|
||||
seccompProfile: "unconfined",
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
buildSandboxCreateArgs({
|
||||
name: "openclaw-sbx-seccomp",
|
||||
cfg,
|
||||
scopeKey: "main",
|
||||
createdAtMs: 1700000000000,
|
||||
}),
|
||||
).toThrow(/seccomp profile "unconfined" is blocked/);
|
||||
});
|
||||
|
||||
it("throws on apparmor unconfined", () => {
|
||||
const cfg: SandboxDockerConfig = {
|
||||
image: "openclaw-sandbox:bookworm-slim",
|
||||
containerPrefix: "openclaw-sbx-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: false,
|
||||
tmpfs: [],
|
||||
network: "none",
|
||||
capDrop: [],
|
||||
apparmorProfile: "unconfined",
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
buildSandboxCreateArgs({
|
||||
name: "openclaw-sbx-apparmor",
|
||||
cfg,
|
||||
scopeKey: "main",
|
||||
createdAtMs: 1700000000000,
|
||||
}),
|
||||
).toThrow(/apparmor profile "unconfined" is blocked/);
|
||||
});
|
||||
|
||||
it("omits -v flags when binds is empty or undefined", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { captureFullEnv } from "../test-utils/env.js";
|
||||
import { resolveSandboxContext } from "./sandbox.js";
|
||||
|
||||
vi.mock("./sandbox/docker.js", () => ({
|
||||
@@ -27,30 +28,15 @@ async function writeSkill(params: { dir: string; name: string; description: stri
|
||||
);
|
||||
}
|
||||
|
||||
function restoreEnv(snapshot: Record<string, string | undefined>) {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in snapshot)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("sandbox skill mirroring", () => {
|
||||
let envSnapshot: Record<string, string | undefined>;
|
||||
let envSnapshot: ReturnType<typeof captureFullEnv>;
|
||||
|
||||
beforeEach(() => {
|
||||
envSnapshot = { ...process.env };
|
||||
envSnapshot = captureFullEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv(envSnapshot);
|
||||
envSnapshot.restore();
|
||||
});
|
||||
|
||||
const runContext = async (workspaceAccess: "none" | "ro") => {
|
||||
|
||||
@@ -111,6 +111,7 @@ import { computeSandboxConfigHash } from "./config-hash.js";
|
||||
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||
import { readRegistry, updateRegistry } from "./registry.js";
|
||||
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
|
||||
import { validateSandboxSecurity } from "./validate-sandbox-security.js";
|
||||
|
||||
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -240,6 +241,9 @@ export function buildSandboxCreateArgs(params: {
|
||||
labels?: Record<string, string>;
|
||||
configHash?: string;
|
||||
}) {
|
||||
// Runtime security validation: blocks dangerous bind mounts, network modes, and profiles.
|
||||
validateSandboxSecurity(params.cfg);
|
||||
|
||||
const createdAtMs = params.createdAtMs ?? Date.now();
|
||||
const args = ["create", "--name", params.name];
|
||||
args.push("--label", "openclaw.sandbox=1");
|
||||
|
||||
145
src/agents/sandbox/validate-sandbox-security.test.ts
Normal file
145
src/agents/sandbox/validate-sandbox-security.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { mkdtempSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBlockedBindReason,
|
||||
validateBindMounts,
|
||||
validateNetworkMode,
|
||||
validateSeccompProfile,
|
||||
validateApparmorProfile,
|
||||
validateSandboxSecurity,
|
||||
} from "./validate-sandbox-security.js";
|
||||
|
||||
describe("getBlockedBindReason", () => {
|
||||
it("blocks common Docker socket directories", () => {
|
||||
expect(getBlockedBindReason("/run:/run")).toEqual(expect.objectContaining({ kind: "targets" }));
|
||||
expect(getBlockedBindReason("/var/run:/var/run:ro")).toEqual(
|
||||
expect.objectContaining({ kind: "targets" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block /var by default", () => {
|
||||
expect(getBlockedBindReason("/var:/var")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateBindMounts", () => {
|
||||
it("allows legitimate project directory mounts", () => {
|
||||
expect(() =>
|
||||
validateBindMounts([
|
||||
"/home/user/source:/source:rw",
|
||||
"/home/user/projects:/projects:ro",
|
||||
"/var/data/myapp:/data",
|
||||
"/opt/myapp/config:/config:ro",
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows undefined or empty binds", () => {
|
||||
expect(() => validateBindMounts(undefined)).not.toThrow();
|
||||
expect(() => validateBindMounts([])).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks /etc mount", () => {
|
||||
expect(() => validateBindMounts(["/etc/passwd:/mnt/passwd:ro"])).toThrow(
|
||||
/blocked path "\/etc"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks /proc mount", () => {
|
||||
expect(() => validateBindMounts(["/proc:/proc:ro"])).toThrow(/blocked path "\/proc"/);
|
||||
});
|
||||
|
||||
it("blocks Docker socket mounts (/var/run + /run)", () => {
|
||||
expect(() => validateBindMounts(["/var/run/docker.sock:/var/run/docker.sock"])).toThrow(
|
||||
/docker\.sock/,
|
||||
);
|
||||
expect(() => validateBindMounts(["/run/docker.sock:/run/docker.sock"])).toThrow(/docker\.sock/);
|
||||
});
|
||||
|
||||
it("blocks parent mounts that would expose the Docker socket", () => {
|
||||
expect(() => validateBindMounts(["/run:/run"])).toThrow(/blocked path/);
|
||||
expect(() => validateBindMounts(["/var/run:/var/run"])).toThrow(/blocked path/);
|
||||
expect(() => validateBindMounts(["/var:/var"])).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks paths with .. traversal to dangerous directories", () => {
|
||||
expect(() => validateBindMounts(["/home/user/../../etc/shadow:/mnt/shadow"])).toThrow(
|
||||
/blocked path "\/etc"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks paths with double slashes normalizing to dangerous dirs", () => {
|
||||
expect(() => validateBindMounts(["//etc//passwd:/mnt/passwd"])).toThrow(/blocked path "\/etc"/);
|
||||
});
|
||||
|
||||
it("blocks symlink escapes into blocked directories", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
|
||||
const link = join(dir, "etc-link");
|
||||
symlinkSync("/etc", link);
|
||||
expect(() => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`])).toThrow(/blocked path/);
|
||||
});
|
||||
|
||||
it("rejects non-absolute source paths (relative or named volumes)", () => {
|
||||
expect(() => validateBindMounts(["../etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/);
|
||||
expect(() => validateBindMounts(["etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/);
|
||||
expect(() => validateBindMounts(["myvol:/mnt"])).toThrow(/non-absolute/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateNetworkMode", () => {
|
||||
it("allows bridge/none/custom/undefined", () => {
|
||||
expect(() => validateNetworkMode("bridge")).not.toThrow();
|
||||
expect(() => validateNetworkMode("none")).not.toThrow();
|
||||
expect(() => validateNetworkMode("my-custom-network")).not.toThrow();
|
||||
expect(() => validateNetworkMode(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks host mode (case-insensitive)", () => {
|
||||
expect(() => validateNetworkMode("host")).toThrow(/network mode "host" is blocked/);
|
||||
expect(() => validateNetworkMode("HOST")).toThrow(/network mode "HOST" is blocked/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSeccompProfile", () => {
|
||||
it("allows custom profile paths/undefined", () => {
|
||||
expect(() => validateSeccompProfile("/tmp/seccomp.json")).not.toThrow();
|
||||
expect(() => validateSeccompProfile(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks unconfined (case-insensitive)", () => {
|
||||
expect(() => validateSeccompProfile("unconfined")).toThrow(
|
||||
/seccomp profile "unconfined" is blocked/,
|
||||
);
|
||||
expect(() => validateSeccompProfile("Unconfined")).toThrow(
|
||||
/seccomp profile "Unconfined" is blocked/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateApparmorProfile", () => {
|
||||
it("allows named profile/undefined", () => {
|
||||
expect(() => validateApparmorProfile("openclaw-sandbox")).not.toThrow();
|
||||
expect(() => validateApparmorProfile(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks unconfined (case-insensitive)", () => {
|
||||
expect(() => validateApparmorProfile("unconfined")).toThrow(
|
||||
/apparmor profile "unconfined" is blocked/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSandboxSecurity", () => {
|
||||
it("passes with safe config", () => {
|
||||
expect(() =>
|
||||
validateSandboxSecurity({
|
||||
binds: ["/home/user/src:/src:rw"],
|
||||
network: "none",
|
||||
seccompProfile: "/tmp/seccomp.json",
|
||||
apparmorProfile: "openclaw-sandbox",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
195
src/agents/sandbox/validate-sandbox-security.ts
Normal file
195
src/agents/sandbox/validate-sandbox-security.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Sandbox security validation — blocks dangerous Docker configurations.
|
||||
*
|
||||
* Threat model: local-trusted config, but protect against foot-guns and config injection.
|
||||
* Enforced at runtime when creating sandbox containers.
|
||||
*/
|
||||
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { posix } from "node:path";
|
||||
|
||||
// Targeted denylist: host paths that should never be exposed inside sandbox containers.
|
||||
// Exported for reuse in security audit collectors.
|
||||
export const BLOCKED_HOST_PATHS = [
|
||||
"/etc",
|
||||
"/private/etc",
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/dev",
|
||||
"/root",
|
||||
"/boot",
|
||||
// Directories that commonly contain (or alias) the Docker socket.
|
||||
"/run",
|
||||
"/var/run",
|
||||
"/private/var/run",
|
||||
"/var/run/docker.sock",
|
||||
"/private/var/run/docker.sock",
|
||||
"/run/docker.sock",
|
||||
];
|
||||
|
||||
const BLOCKED_NETWORK_MODES = new Set(["host"]);
|
||||
const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]);
|
||||
const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]);
|
||||
|
||||
export type BlockedBindReason =
|
||||
| { kind: "targets"; blockedPath: string }
|
||||
| { kind: "covers"; blockedPath: string }
|
||||
| { kind: "non_absolute"; sourcePath: string };
|
||||
|
||||
/**
|
||||
* Parse the host/source path from a Docker bind mount string.
|
||||
* Format: `source:target[:mode]`
|
||||
*/
|
||||
export function parseBindSourcePath(bind: string): string {
|
||||
const trimmed = bind.trim();
|
||||
const firstColon = trimmed.indexOf(":");
|
||||
if (firstColon <= 0) {
|
||||
// No colon or starts with colon — treat as source.
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, firstColon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
|
||||
*/
|
||||
export function normalizeHostPath(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
return posix.normalize(trimmed).replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* String-only blocked-path check (no filesystem I/O).
|
||||
* Blocks:
|
||||
* - binds that target blocked paths (equal or under)
|
||||
* - binds that cover the system root (mounting "/" is never safe)
|
||||
* - non-absolute source paths (relative / volume names) because they are hard to validate safely
|
||||
*/
|
||||
export function getBlockedBindReason(bind: string): BlockedBindReason | null {
|
||||
const sourceRaw = parseBindSourcePath(bind);
|
||||
if (!sourceRaw.startsWith("/")) {
|
||||
return { kind: "non_absolute", sourcePath: sourceRaw };
|
||||
}
|
||||
|
||||
const normalized = normalizeHostPath(sourceRaw);
|
||||
return getBlockedReasonForSourcePath(normalized);
|
||||
}
|
||||
|
||||
export function getBlockedReasonForSourcePath(sourceNormalized: string): BlockedBindReason | null {
|
||||
if (sourceNormalized === "/") {
|
||||
return { kind: "covers", blockedPath: "/" };
|
||||
}
|
||||
for (const blocked of BLOCKED_HOST_PATHS) {
|
||||
if (sourceNormalized === blocked || sourceNormalized.startsWith(blocked + "/")) {
|
||||
return { kind: "targets", blockedPath: blocked };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryRealpathAbsolute(path: string): string {
|
||||
if (!path.startsWith("/")) {
|
||||
return path;
|
||||
}
|
||||
if (!existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
try {
|
||||
// Use native when available (keeps platform semantics); normalize for prefix checks.
|
||||
return normalizeHostPath(realpathSync.native(path));
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBindBlockedError(params: { bind: string; reason: BlockedBindReason }): Error {
|
||||
if (params.reason.kind === "non_absolute") {
|
||||
return new Error(
|
||||
`Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` +
|
||||
`"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`,
|
||||
);
|
||||
}
|
||||
const verb = params.reason.kind === "covers" ? "covers" : "targets";
|
||||
return new Error(
|
||||
`Sandbox security: bind mount "${params.bind}" ${verb} blocked path "${params.reason.blockedPath}". ` +
|
||||
"Mounting system directories (or Docker socket paths) into sandbox containers is not allowed. " +
|
||||
"Use project-specific paths instead (e.g. /home/user/myproject).",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bind mounts — throws if any source path is dangerous.
|
||||
* Includes a symlink/realpath pass when the source path exists.
|
||||
*/
|
||||
export function validateBindMounts(binds: string[] | undefined): void {
|
||||
if (!binds?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rawBind of binds) {
|
||||
const bind = rawBind.trim();
|
||||
if (!bind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fast string-only check (covers .., //, ancestor/descendant logic).
|
||||
const blocked = getBlockedBindReason(bind);
|
||||
if (blocked) {
|
||||
throw formatBindBlockedError({ bind, reason: blocked });
|
||||
}
|
||||
|
||||
// Symlink escape hardening: resolve existing absolute paths and re-check.
|
||||
const sourceRaw = parseBindSourcePath(bind);
|
||||
const sourceNormalized = normalizeHostPath(sourceRaw);
|
||||
const sourceReal = tryRealpathAbsolute(sourceNormalized);
|
||||
if (sourceReal !== sourceNormalized) {
|
||||
const reason = getBlockedReasonForSourcePath(sourceReal);
|
||||
if (reason) {
|
||||
throw formatBindBlockedError({ bind, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateNetworkMode(network: string | undefined): void {
|
||||
if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) {
|
||||
throw new Error(
|
||||
`Sandbox security: network mode "${network}" is blocked. ` +
|
||||
'Network "host" mode bypasses container network isolation. ' +
|
||||
'Use "bridge" or "none" instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSeccompProfile(profile: string | undefined): void {
|
||||
if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) {
|
||||
throw new Error(
|
||||
`Sandbox security: seccomp profile "${profile}" is blocked. ` +
|
||||
"Disabling seccomp removes syscall filtering and weakens sandbox isolation. " +
|
||||
"Use a custom seccomp profile file or omit this setting.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateApparmorProfile(profile: string | undefined): void {
|
||||
if (profile && BLOCKED_APPARMOR_PROFILES.has(profile.trim().toLowerCase())) {
|
||||
throw new Error(
|
||||
`Sandbox security: apparmor profile "${profile}" is blocked. ` +
|
||||
"Disabling AppArmor removes mandatory access controls and weakens sandbox isolation. " +
|
||||
"Use a named AppArmor profile or omit this setting.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSandboxSecurity(cfg: {
|
||||
binds?: string[];
|
||||
network?: string;
|
||||
seccompProfile?: string;
|
||||
apparmorProfile?: string;
|
||||
}): void {
|
||||
validateBindMounts(cfg.binds);
|
||||
validateNetworkMode(cfg.network);
|
||||
validateSeccompProfile(cfg.seccompProfile);
|
||||
validateApparmorProfile(cfg.apparmorProfile);
|
||||
}
|
||||
53
src/agents/sanitize-for-prompt.test.ts
Normal file
53
src/agents/sanitize-for-prompt.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js";
|
||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
|
||||
describe("sanitizeForPromptLiteral (OC-19 hardening)", () => {
|
||||
it("strips ASCII control chars (CR/LF/NUL/tab)", () => {
|
||||
expect(sanitizeForPromptLiteral("/tmp/a\nb\rc\x00d\te")).toBe("/tmp/abcde");
|
||||
});
|
||||
|
||||
it("strips Unicode line/paragraph separators", () => {
|
||||
expect(sanitizeForPromptLiteral(`/tmp/a\u2028b\u2029c`)).toBe("/tmp/abc");
|
||||
});
|
||||
|
||||
it("strips Unicode format chars (bidi override)", () => {
|
||||
// U+202E RIGHT-TO-LEFT OVERRIDE (Cf) can spoof rendered text.
|
||||
expect(sanitizeForPromptLiteral(`/tmp/a\u202Eb`)).toBe("/tmp/ab");
|
||||
});
|
||||
|
||||
it("preserves ordinary Unicode + spaces", () => {
|
||||
const value = "/tmp/my project/日本語-folder.v2";
|
||||
expect(sanitizeForPromptLiteral(value)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentSystemPrompt uses sanitized workspace/sandbox strings", () => {
|
||||
it("sanitizes workspaceDir (no newlines / separators)", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/project\nINJECT\u2028MORE",
|
||||
});
|
||||
expect(prompt).toContain("Your working directory is: /tmp/projectINJECTMORE");
|
||||
expect(prompt).not.toContain("Your working directory is: /tmp/project\n");
|
||||
expect(prompt).not.toContain("\u2028");
|
||||
});
|
||||
|
||||
it("sanitizes sandbox workspace/mount/url strings", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/test",
|
||||
sandboxInfo: {
|
||||
enabled: true,
|
||||
containerWorkspaceDir: "/work\u2029space",
|
||||
workspaceDir: "/host\nspace",
|
||||
workspaceAccess: "read-write",
|
||||
agentWorkspaceMount: "/mnt\u2028mount",
|
||||
browserNoVncUrl: "http://example.test/\nui",
|
||||
},
|
||||
});
|
||||
expect(prompt).toContain("Sandbox container workdir: /workspace");
|
||||
expect(prompt).toContain("Sandbox host workspace: /hostspace");
|
||||
expect(prompt).toContain("(mounted at /mntmount)");
|
||||
expect(prompt).toContain("Sandbox browser observer (noVNC): http://example.test/ui");
|
||||
expect(prompt).not.toContain("\nui");
|
||||
});
|
||||
});
|
||||
18
src/agents/sanitize-for-prompt.ts
Normal file
18
src/agents/sanitize-for-prompt.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Sanitize untrusted strings before embedding them into an LLM prompt.
|
||||
*
|
||||
* Threat model (OC-19): attacker-controlled directory names (or other runtime strings)
|
||||
* that contain newline/control characters can break prompt structure and inject
|
||||
* arbitrary instructions.
|
||||
*
|
||||
* Strategy (Option 3 hardening):
|
||||
* - Strip Unicode "control" (Cc) + "format" (Cf) characters (includes CR/LF/NUL, bidi marks, zero-width chars).
|
||||
* - Strip explicit line/paragraph separators (Zl/Zp): U+2028/U+2029.
|
||||
*
|
||||
* Notes:
|
||||
* - This is intentionally lossy; it trades edge-case path fidelity for prompt integrity.
|
||||
* - If you need lossless representation, escape instead of stripping.
|
||||
*/
|
||||
export function sanitizeForPromptLiteral(value: string): string {
|
||||
return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "");
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
normalizeStringList,
|
||||
parseFrontmatterBool,
|
||||
resolveOpenClawManifestBlock,
|
||||
resolveOpenClawManifestInstall,
|
||||
resolveOpenClawManifestOs,
|
||||
resolveOpenClawManifestRequires,
|
||||
} from "../../shared/frontmatter.js";
|
||||
|
||||
export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
|
||||
@@ -83,15 +86,9 @@ export function resolveOpenClawMetadata(
|
||||
if (!metadataObj) {
|
||||
return undefined;
|
||||
}
|
||||
const requiresRaw =
|
||||
typeof metadataObj.requires === "object" && metadataObj.requires !== null
|
||||
? (metadataObj.requires as Record<string, unknown>)
|
||||
: undefined;
|
||||
const installRaw = Array.isArray(metadataObj.install) ? (metadataObj.install as unknown[]) : [];
|
||||
const install = installRaw
|
||||
.map((entry) => parseInstallSpec(entry))
|
||||
.filter((entry): entry is SkillInstallSpec => Boolean(entry));
|
||||
const osRaw = normalizeStringList(metadataObj.os);
|
||||
const requires = resolveOpenClawManifestRequires(metadataObj);
|
||||
const install = resolveOpenClawManifestInstall(metadataObj, parseInstallSpec);
|
||||
const osRaw = resolveOpenClawManifestOs(metadataObj);
|
||||
return {
|
||||
always: typeof metadataObj.always === "boolean" ? metadataObj.always : undefined,
|
||||
emoji: typeof metadataObj.emoji === "string" ? metadataObj.emoji : undefined,
|
||||
@@ -99,14 +96,7 @@ export function resolveOpenClawMetadata(
|
||||
skillKey: typeof metadataObj.skillKey === "string" ? metadataObj.skillKey : undefined,
|
||||
primaryEnv: typeof metadataObj.primaryEnv === "string" ? metadataObj.primaryEnv : undefined,
|
||||
os: osRaw.length > 0 ? osRaw : undefined,
|
||||
requires: requiresRaw
|
||||
? {
|
||||
bins: normalizeStringList(requiresRaw.bins),
|
||||
anyBins: normalizeStringList(requiresRaw.anyBins),
|
||||
env: normalizeStringList(requiresRaw.env),
|
||||
config: normalizeStringList(requiresRaw.config),
|
||||
}
|
||||
: undefined,
|
||||
requires: requires,
|
||||
install: install.length > 0 ? install : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
initSubagentRegistry,
|
||||
registerSubagentRun,
|
||||
@@ -29,7 +30,7 @@ vi.mock("./subagent-announce.js", () => ({
|
||||
}));
|
||||
|
||||
describe("subagent registry persistence", () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
let tempStateDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -39,11 +40,7 @@ describe("subagent registry persistence", () => {
|
||||
await fs.rm(tempStateDir, { recursive: true, force: true });
|
||||
tempStateDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
});
|
||||
|
||||
it("persists runs to disk and resumes after restart", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ResolvedTimeFormat } from "./date-time.js";
|
||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
||||
import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js";
|
||||
|
||||
/**
|
||||
* Controls which hardcoded sections are included in the system prompt.
|
||||
@@ -355,13 +356,17 @@ export function buildAgentSystemPrompt(params: {
|
||||
const promptMode = params.promptMode ?? "full";
|
||||
const isMinimal = promptMode === "minimal" || promptMode === "none";
|
||||
const sandboxContainerWorkspace = params.sandboxInfo?.containerWorkspaceDir?.trim();
|
||||
const sanitizedWorkspaceDir = sanitizeForPromptLiteral(params.workspaceDir);
|
||||
const sanitizedSandboxContainerWorkspace = sandboxContainerWorkspace
|
||||
? sanitizeForPromptLiteral(sandboxContainerWorkspace)
|
||||
: "";
|
||||
const displayWorkspaceDir =
|
||||
params.sandboxInfo?.enabled && sandboxContainerWorkspace
|
||||
? sandboxContainerWorkspace
|
||||
: params.workspaceDir;
|
||||
params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace
|
||||
? sanitizedSandboxContainerWorkspace
|
||||
: sanitizedWorkspaceDir;
|
||||
const workspaceGuidance =
|
||||
params.sandboxInfo?.enabled && sandboxContainerWorkspace
|
||||
? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${params.workspaceDir}. Prefer relative paths so both sandboxed exec and file tools work consistently.`
|
||||
params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace
|
||||
? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${sanitizedWorkspaceDir}. Prefer relative paths so both sandboxed exec and file tools work consistently.`
|
||||
: "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.";
|
||||
const safetySection = [
|
||||
"## Safety",
|
||||
@@ -480,21 +485,21 @@ export function buildAgentSystemPrompt(params: {
|
||||
"Some tools may be unavailable due to sandbox policy.",
|
||||
"Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.",
|
||||
params.sandboxInfo.containerWorkspaceDir
|
||||
? `Sandbox container workdir: ${params.sandboxInfo.containerWorkspaceDir}`
|
||||
? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}`
|
||||
: "",
|
||||
params.sandboxInfo.workspaceDir
|
||||
? `Sandbox host workspace: ${params.sandboxInfo.workspaceDir}`
|
||||
? `Sandbox host workspace: ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}`
|
||||
: "",
|
||||
params.sandboxInfo.workspaceAccess
|
||||
? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${
|
||||
params.sandboxInfo.agentWorkspaceMount
|
||||
? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})`
|
||||
? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})`
|
||||
: ""
|
||||
}`
|
||||
: "",
|
||||
params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
|
||||
params.sandboxInfo.browserNoVncUrl
|
||||
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
||||
? `Sandbox browser observer (noVNC): ${sanitizeForPromptLiteral(params.sandboxInfo.browserNoVncUrl)}`
|
||||
: "",
|
||||
params.sandboxInfo.hostBrowserAllowed === true
|
||||
? "Host browser control: allowed."
|
||||
|
||||
@@ -1,22 +1 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
const stubTool = (name: string) => ({
|
||||
name,
|
||||
description: `${name} stub`,
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("../tools/image-tool.js", () => ({
|
||||
createImageTool: () => stubTool("image"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/web-tools.js", () => ({
|
||||
createWebSearchTool: () => null,
|
||||
createWebFetchTool: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
getPluginToolMeta: () => undefined,
|
||||
}));
|
||||
import "./fast-tool-stubs.js";
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
const stubTool = (name: string) => ({
|
||||
name,
|
||||
description: `${name} stub`,
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(),
|
||||
});
|
||||
import { stubTool } from "./fast-tool-stubs.js";
|
||||
|
||||
vi.mock("../tools/browser-tool.js", () => ({
|
||||
createBrowserTool: () => stubTool("browser"),
|
||||
@@ -14,17 +8,3 @@ vi.mock("../tools/browser-tool.js", () => ({
|
||||
vi.mock("../tools/canvas-tool.js", () => ({
|
||||
createCanvasTool: () => stubTool("canvas"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/image-tool.js", () => ({
|
||||
createImageTool: () => stubTool("image"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/web-tools.js", () => ({
|
||||
createWebSearchTool: () => null,
|
||||
createWebFetchTool: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
getPluginToolMeta: () => undefined,
|
||||
}));
|
||||
|
||||
22
src/agents/test-helpers/fast-tool-stubs.ts
Normal file
22
src/agents/test-helpers/fast-tool-stubs.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const stubTool = (name: string) => ({
|
||||
name,
|
||||
description: `${name} stub`,
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("../tools/image-tool.js", () => ({
|
||||
createImageTool: () => stubTool("image"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/web-tools.js", () => ({
|
||||
createWebSearchTool: () => null,
|
||||
createWebFetchTool: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
getPluginToolMeta: () => undefined,
|
||||
}));
|
||||
@@ -219,7 +219,8 @@ JOB SCHEMA (for add action):
|
||||
"payload": { ... }, // Required: what to execute
|
||||
"delivery": { ... }, // Optional: announce summary (isolated only)
|
||||
"sessionTarget": "main" | "isolated", // Required
|
||||
"enabled": true | false // Optional, default true
|
||||
"enabled": true | false, // Optional, default true
|
||||
"notify": true | false // Optional webhook opt-in; set true for user-facing reminders
|
||||
}
|
||||
|
||||
SCHEDULE TYPES (schedule.kind):
|
||||
@@ -246,6 +247,7 @@ DELIVERY (isolated-only, top-level):
|
||||
CRITICAL CONSTRAINTS:
|
||||
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
|
||||
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
|
||||
- For reminders users should be notified about, set notify=true.
|
||||
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event.
|
||||
|
||||
WAKE MODES (for wake action):
|
||||
@@ -292,6 +294,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
||||
"payload",
|
||||
"delivery",
|
||||
"enabled",
|
||||
"notify",
|
||||
"description",
|
||||
"deleteAfterRun",
|
||||
"agentId",
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../memory/index.js", () => {
|
||||
return {
|
||||
getMemorySearchManager: async () => {
|
||||
return {
|
||||
manager: {
|
||||
search: async () => {
|
||||
throw new Error("openai embeddings failed: 429 insufficient_quota");
|
||||
},
|
||||
readFile: async () => {
|
||||
throw new Error("path required");
|
||||
},
|
||||
status: () => ({
|
||||
files: 0,
|
||||
chunks: 0,
|
||||
dirty: true,
|
||||
workspaceDir: "/tmp",
|
||||
dbPath: "/tmp/index.sqlite",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js";
|
||||
|
||||
describe("memory tools", () => {
|
||||
it("does not throw when memory_search fails (e.g. embeddings 429)", async () => {
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
||||
const tool = createMemorySearchTool({ config: cfg });
|
||||
expect(tool).not.toBeNull();
|
||||
if (!tool) {
|
||||
throw new Error("tool missing");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call_1", { query: "hello" });
|
||||
expect(result.details).toEqual({
|
||||
results: [],
|
||||
disabled: true,
|
||||
error: "openai embeddings failed: 429 insufficient_quota",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not throw when memory_get fails", async () => {
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
||||
const tool = createMemoryGetTool({ config: cfg });
|
||||
expect(tool).not.toBeNull();
|
||||
if (!tool) {
|
||||
throw new Error("tool missing");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call_2", { path: "memory/NOPE.md" });
|
||||
expect(result.details).toEqual({
|
||||
path: "memory/NOPE.md",
|
||||
text: "",
|
||||
disabled: true,
|
||||
error: "path required",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let backend: "builtin" | "qmd" = "builtin";
|
||||
let searchImpl: () => Promise<unknown[]> = async () => [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 5,
|
||||
endLine: 7,
|
||||
score: 0.9,
|
||||
snippet: "@@ -5,3 @@\nAssistant: noted",
|
||||
source: "memory" as const,
|
||||
},
|
||||
];
|
||||
let readFileImpl: () => Promise<string> = async () => "";
|
||||
|
||||
const stubManager = {
|
||||
search: vi.fn(async () => [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 5,
|
||||
endLine: 7,
|
||||
score: 0.9,
|
||||
snippet: "@@ -5,3 @@\nAssistant: noted",
|
||||
source: "memory" as const,
|
||||
},
|
||||
]),
|
||||
readFile: vi.fn(),
|
||||
search: vi.fn(async () => await searchImpl()),
|
||||
readFile: vi.fn(async () => await readFileImpl()),
|
||||
status: () => ({
|
||||
backend,
|
||||
files: 1,
|
||||
@@ -37,9 +40,21 @@ vi.mock("../../memory/index.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createMemorySearchTool } from "./memory-tool.js";
|
||||
import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js";
|
||||
|
||||
beforeEach(() => {
|
||||
backend = "builtin";
|
||||
searchImpl = async () => [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 5,
|
||||
endLine: 7,
|
||||
score: 0.9,
|
||||
snippet: "@@ -5,3 @@\nAssistant: noted",
|
||||
source: "memory" as const,
|
||||
},
|
||||
];
|
||||
readFileImpl = async () => "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -121,3 +136,46 @@ describe("memory search citations", () => {
|
||||
expect(details.results[0]?.snippet).not.toMatch(/Source:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory tools", () => {
|
||||
it("does not throw when memory_search fails (e.g. embeddings 429)", async () => {
|
||||
searchImpl = async () => {
|
||||
throw new Error("openai embeddings failed: 429 insufficient_quota");
|
||||
};
|
||||
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
||||
const tool = createMemorySearchTool({ config: cfg });
|
||||
expect(tool).not.toBeNull();
|
||||
if (!tool) {
|
||||
throw new Error("tool missing");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call_1", { query: "hello" });
|
||||
expect(result.details).toEqual({
|
||||
results: [],
|
||||
disabled: true,
|
||||
error: "openai embeddings failed: 429 insufficient_quota",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not throw when memory_get fails", async () => {
|
||||
readFileImpl = async () => {
|
||||
throw new Error("path required");
|
||||
};
|
||||
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
||||
const tool = createMemoryGetTool({ config: cfg });
|
||||
expect(tool).not.toBeNull();
|
||||
if (!tool) {
|
||||
throw new Error("tool missing");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call_2", { path: "memory/NOPE.md" });
|
||||
expect(result.details).toEqual({
|
||||
path: "memory/NOPE.md",
|
||||
text: "",
|
||||
disabled: true,
|
||||
error: "path required",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js");
|
||||
|
||||
const installRegistry = async () => {
|
||||
const { setActivePluginRegistry } = await import("../../plugins/runtime.js");
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "Discord test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "channel", "thread"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "whatsapp",
|
||||
meta: {
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
selectionLabel: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
blurb: "WhatsApp test stub.",
|
||||
preferSessionLookupForAnnounceTarget: true,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
describe("resolveAnnounceTarget", () => {
|
||||
beforeEach(async () => {
|
||||
callGatewayMock.mockReset();
|
||||
await installRegistry();
|
||||
});
|
||||
|
||||
it("derives non-WhatsApp announce targets from the session key", async () => {
|
||||
const { resolveAnnounceTarget } = await loadResolveAnnounceTarget();
|
||||
const target = await resolveAnnounceTarget({
|
||||
sessionKey: "agent:main:discord:group:dev",
|
||||
displayKey: "agent:main:discord:group:dev",
|
||||
});
|
||||
expect(target).toEqual({ channel: "discord", to: "channel:dev" });
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hydrates WhatsApp accountId from sessions.list when available", async () => {
|
||||
const { resolveAnnounceTarget } = await loadResolveAnnounceTarget();
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:whatsapp:group:123@g.us",
|
||||
deliveryContext: {
|
||||
channel: "whatsapp",
|
||||
to: "123@g.us",
|
||||
accountId: "work",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const target = await resolveAnnounceTarget({
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
displayKey: "agent:main:whatsapp:group:123@g.us",
|
||||
});
|
||||
expect(target).toEqual({
|
||||
channel: "whatsapp",
|
||||
to: "123@g.us",
|
||||
accountId: "work",
|
||||
});
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined;
|
||||
expect(first).toBeDefined();
|
||||
expect(first?.method).toBe("sessions.list");
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
|
||||
|
||||
describe("sanitizeTextContent", () => {
|
||||
it("strips minimax tool call XML and downgraded markers", () => {
|
||||
const input =
|
||||
'Hello <invoke name="tool">payload</invoke></minimax:tool_call> ' +
|
||||
"[Tool Call: foo (ID: 1)] world";
|
||||
const result = sanitizeTextContent(input).trim();
|
||||
expect(result).toBe("Hello world");
|
||||
expect(result).not.toContain("invoke");
|
||||
expect(result).not.toContain("Tool Call");
|
||||
});
|
||||
|
||||
it("strips thinking tags", () => {
|
||||
const input = "Before <think>secret</think> after";
|
||||
const result = sanitizeTextContent(input).trim();
|
||||
expect(result).toBe("Before after");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAssistantText", () => {
|
||||
it("sanitizes blocks without injecting newlines", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Hi " },
|
||||
{ type: "text", text: "<think>secret</think>there" },
|
||||
],
|
||||
};
|
||||
expect(extractAssistantText(message)).toBe("Hi there");
|
||||
});
|
||||
|
||||
it("rewrites error-ish assistant text only when the transcript marks it as an error", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "500 Internal Server Error",
|
||||
content: [{ type: "text", text: "500 Internal Server Error" }],
|
||||
};
|
||||
expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error");
|
||||
});
|
||||
|
||||
it("keeps normal status text that mentions billing", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractAssistantText(message)).toBe(
|
||||
"Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () =>
|
||||
({
|
||||
session: { scope: "per-sender", mainKey: "main" },
|
||||
tools: { agentToAgent: { enabled: false } },
|
||||
}) as never,
|
||||
};
|
||||
});
|
||||
|
||||
import { createSessionsListTool } from "./sessions-list-tool.js";
|
||||
|
||||
describe("sessions_list gating", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockReset();
|
||||
callGatewayMock.mockResolvedValue({
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [
|
||||
{ key: "agent:main:main", kind: "direct" },
|
||||
{ key: "agent:other:main", kind: "direct" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
|
||||
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
|
||||
const result = await tool.execute("call1", {});
|
||||
expect(result.details).toMatchObject({
|
||||
count: 1,
|
||||
sessions: [{ key: "agent:main:main" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () =>
|
||||
({
|
||||
session: { scope: "per-sender", mainKey: "main" },
|
||||
tools: { agentToAgent: { enabled: false } },
|
||||
}) as never,
|
||||
};
|
||||
});
|
||||
|
||||
import { createSessionsSendTool } from "./sessions-send-tool.js";
|
||||
|
||||
describe("sessions_send gating", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockReset();
|
||||
});
|
||||
|
||||
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
|
||||
const tool = createSessionsSendTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
sessionKey: "agent:other:main",
|
||||
message: "hi",
|
||||
timeoutSeconds: 0,
|
||||
});
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
expect(result.details).toMatchObject({ status: "forbidden" });
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const SessionsSpawnToolSchema = Type.Object({
|
||||
model: Type.Optional(Type.String()),
|
||||
thinking: Type.Optional(Type.String()),
|
||||
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
// Back-compat: older callers used timeoutSeconds for this tool.
|
||||
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
cleanup: optionalStringEnum(["delete", "keep"] as const),
|
||||
});
|
||||
@@ -98,14 +99,16 @@ export function createSessionsSpawnTool(opts?: {
|
||||
});
|
||||
// Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived
|
||||
// by default and should not inherit the main agent 600s timeout.
|
||||
const legacyTimeoutSeconds =
|
||||
typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds)
|
||||
? Math.max(0, Math.floor(params.timeoutSeconds))
|
||||
: undefined;
|
||||
const timeoutSecondsCandidate =
|
||||
typeof params.runTimeoutSeconds === "number"
|
||||
? params.runTimeoutSeconds
|
||||
: typeof params.timeoutSeconds === "number"
|
||||
? params.timeoutSeconds
|
||||
: undefined;
|
||||
const runTimeoutSeconds =
|
||||
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
|
||||
? Math.max(0, Math.floor(params.runTimeoutSeconds))
|
||||
: (legacyTimeoutSeconds ?? 0);
|
||||
typeof timeoutSecondsCandidate === "number" && Number.isFinite(timeoutSecondsCandidate)
|
||||
? Math.max(0, Math.floor(timeoutSecondsCandidate))
|
||||
: 0;
|
||||
let modelWarning: string | undefined;
|
||||
let modelApplied = false;
|
||||
|
||||
|
||||
219
src/agents/tools/sessions.e2e.test.ts
Normal file
219
src/agents/tools/sessions.e2e.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () =>
|
||||
({
|
||||
session: { scope: "per-sender", mainKey: "main" },
|
||||
tools: { agentToAgent: { enabled: false } },
|
||||
}) as never,
|
||||
};
|
||||
});
|
||||
|
||||
import { createSessionsListTool } from "./sessions-list-tool.js";
|
||||
import { createSessionsSendTool } from "./sessions-send-tool.js";
|
||||
|
||||
const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js");
|
||||
|
||||
const installRegistry = async () => {
|
||||
const { setActivePluginRegistry } = await import("../../plugins/runtime.js");
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "Discord test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "channel", "thread"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "whatsapp",
|
||||
meta: {
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
selectionLabel: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
blurb: "WhatsApp test stub.",
|
||||
preferSessionLookupForAnnounceTarget: true,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
describe("sanitizeTextContent", () => {
|
||||
it("strips minimax tool call XML and downgraded markers", () => {
|
||||
const input =
|
||||
'Hello <invoke name="tool">payload</invoke></minimax:tool_call> ' +
|
||||
"[Tool Call: foo (ID: 1)] world";
|
||||
const result = sanitizeTextContent(input).trim();
|
||||
expect(result).toBe("Hello world");
|
||||
expect(result).not.toContain("invoke");
|
||||
expect(result).not.toContain("Tool Call");
|
||||
});
|
||||
|
||||
it("strips thinking tags", () => {
|
||||
const input = "Before <think>secret</think> after";
|
||||
const result = sanitizeTextContent(input).trim();
|
||||
expect(result).toBe("Before after");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAssistantText", () => {
|
||||
it("sanitizes blocks without injecting newlines", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Hi " },
|
||||
{ type: "text", text: "<think>secret</think>there" },
|
||||
],
|
||||
};
|
||||
expect(extractAssistantText(message)).toBe("Hi there");
|
||||
});
|
||||
|
||||
it("rewrites error-ish assistant text only when the transcript marks it as an error", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "500 Internal Server Error",
|
||||
content: [{ type: "text", text: "500 Internal Server Error" }],
|
||||
};
|
||||
expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error");
|
||||
});
|
||||
|
||||
it("keeps normal status text that mentions billing", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractAssistantText(message)).toBe(
|
||||
"Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAnnounceTarget", () => {
|
||||
beforeEach(async () => {
|
||||
callGatewayMock.mockReset();
|
||||
await installRegistry();
|
||||
});
|
||||
|
||||
it("derives non-WhatsApp announce targets from the session key", async () => {
|
||||
const { resolveAnnounceTarget } = await loadResolveAnnounceTarget();
|
||||
const target = await resolveAnnounceTarget({
|
||||
sessionKey: "agent:main:discord:group:dev",
|
||||
displayKey: "agent:main:discord:group:dev",
|
||||
});
|
||||
expect(target).toEqual({ channel: "discord", to: "channel:dev" });
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hydrates WhatsApp accountId from sessions.list when available", async () => {
|
||||
const { resolveAnnounceTarget } = await loadResolveAnnounceTarget();
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:whatsapp:group:123@g.us",
|
||||
deliveryContext: {
|
||||
channel: "whatsapp",
|
||||
to: "123@g.us",
|
||||
accountId: "work",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const target = await resolveAnnounceTarget({
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
displayKey: "agent:main:whatsapp:group:123@g.us",
|
||||
});
|
||||
expect(target).toEqual({
|
||||
channel: "whatsapp",
|
||||
to: "123@g.us",
|
||||
accountId: "work",
|
||||
});
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined;
|
||||
expect(first).toBeDefined();
|
||||
expect(first?.method).toBe("sessions.list");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions_list gating", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockReset();
|
||||
callGatewayMock.mockResolvedValue({
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [
|
||||
{ key: "agent:main:main", kind: "direct" },
|
||||
{ key: "agent:other:main", kind: "direct" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
|
||||
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
|
||||
const result = await tool.execute("call1", {});
|
||||
expect(result.details).toMatchObject({
|
||||
count: 1,
|
||||
sessions: [{ key: "agent:main:main" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions_send gating", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockReset();
|
||||
});
|
||||
|
||||
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
|
||||
const tool = createSessionsSendTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
sessionKey: "agent:other:main",
|
||||
message: "hi",
|
||||
timeoutSeconds: 0,
|
||||
});
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
expect(result.details).toMatchObject({ status: "forbidden" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
export type ExtractMode = "markdown" | "text";
|
||||
|
||||
const READABILITY_MAX_HTML_CHARS = 1_000_000;
|
||||
const READABILITY_MAX_ESTIMATED_NESTING_DEPTH = 3_000;
|
||||
|
||||
let readabilityDepsPromise:
|
||||
| Promise<{
|
||||
Readability: typeof import("@mozilla/readability").Readability;
|
||||
@@ -107,6 +110,100 @@ export function truncateText(
|
||||
return { text: value.slice(0, maxChars), truncated: true };
|
||||
}
|
||||
|
||||
function exceedsEstimatedHtmlNestingDepth(html: string, maxDepth: number): boolean {
|
||||
// Cheap heuristic to skip Readability+DOM parsing on pathological HTML (deep nesting => stack/memory blowups).
|
||||
// Not an HTML parser; tuned to catch attacker-controlled "<div><div>..." cases.
|
||||
const voidTags = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
let depth = 0;
|
||||
const len = html.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (html.charCodeAt(i) !== 60) {
|
||||
continue; // '<'
|
||||
}
|
||||
const next = html.charCodeAt(i + 1);
|
||||
if (next === 33 || next === 63) {
|
||||
continue; // <! ...> or <? ...>
|
||||
}
|
||||
|
||||
let j = i + 1;
|
||||
let closing = false;
|
||||
if (html.charCodeAt(j) === 47) {
|
||||
closing = true;
|
||||
j += 1;
|
||||
}
|
||||
|
||||
while (j < len && html.charCodeAt(j) <= 32) {
|
||||
j += 1;
|
||||
}
|
||||
|
||||
const nameStart = j;
|
||||
while (j < len) {
|
||||
const c = html.charCodeAt(j);
|
||||
const isNameChar =
|
||||
(c >= 65 && c <= 90) || // A-Z
|
||||
(c >= 97 && c <= 122) || // a-z
|
||||
(c >= 48 && c <= 57) || // 0-9
|
||||
c === 58 || // :
|
||||
c === 45; // -
|
||||
if (!isNameChar) {
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
|
||||
const tagName = html.slice(nameStart, j).toLowerCase();
|
||||
if (!tagName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (closing) {
|
||||
depth = Math.max(0, depth - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (voidTags.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Best-effort self-closing detection: scan a short window for "/>".
|
||||
let selfClosing = false;
|
||||
for (let k = j; k < len && k < j + 200; k++) {
|
||||
const c = html.charCodeAt(k);
|
||||
if (c === 62) {
|
||||
if (html.charCodeAt(k - 1) === 47) {
|
||||
selfClosing = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selfClosing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
depth += 1;
|
||||
if (depth > maxDepth) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function extractReadableContent(params: {
|
||||
html: string;
|
||||
url: string;
|
||||
@@ -120,6 +217,12 @@ export async function extractReadableContent(params: {
|
||||
}
|
||||
return rendered;
|
||||
};
|
||||
if (
|
||||
params.html.length > READABILITY_MAX_HTML_CHARS ||
|
||||
exceedsEstimatedHtmlNestingDepth(params.html, READABILITY_MAX_ESTIMATED_NESTING_DEPTH)
|
||||
) {
|
||||
return fallback();
|
||||
}
|
||||
try {
|
||||
const { Readability, parseHTML } = await loadReadabilityDeps();
|
||||
const { document } = parseHTML(params.html);
|
||||
|
||||
66
src/agents/tools/web-fetch.response-limit.test.ts
Normal file
66
src/agents/tools/web-fetch.response-limit.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as ssrf from "../../infra/net/ssrf.js";
|
||||
import { createWebFetchTool } from "./web-tools.js";
|
||||
|
||||
// Avoid dynamic-importing heavy readability deps in this unit test suite.
|
||||
vi.mock("./web-fetch-utils.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./web-fetch-utils.js")>("./web-fetch-utils.js");
|
||||
return {
|
||||
...actual,
|
||||
extractReadableContent: vi.fn().mockResolvedValue({
|
||||
title: "HTML Page",
|
||||
text: "HTML Page\n\nContent here.",
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const lookupMock = vi.fn();
|
||||
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
|
||||
const baseToolConfig = {
|
||||
config: {
|
||||
tools: {
|
||||
web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false }, maxResponseBytes: 1024 } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
describe("web_fetch response size limits", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
|
||||
resolvePinnedHostname(hostname, lookupMock),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error restore
|
||||
global.fetch = priorFetch;
|
||||
lookupMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("caps response bytes and does not hang on endless streams", async () => {
|
||||
const chunk = new TextEncoder().encode("<html><body><div>hi</div></body></html>");
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
const response = new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValue(response);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const tool = createWebFetchTool(baseToolConfig);
|
||||
const result = await tool?.execute?.("call", { url: "https://example.com/stream" });
|
||||
|
||||
expect(result?.details?.warning).toContain("Response body truncated");
|
||||
});
|
||||
});
|
||||
@@ -33,8 +33,12 @@ export { extractReadableContent } from "./web-fetch-utils.js";
|
||||
const EXTRACT_MODES = ["markdown", "text"] as const;
|
||||
|
||||
const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
||||
const DEFAULT_FETCH_MAX_RESPONSE_BYTES = 2_000_000;
|
||||
const FETCH_MAX_RESPONSE_BYTES_MIN = 32_000;
|
||||
const FETCH_MAX_RESPONSE_BYTES_MAX = 10_000_000;
|
||||
const DEFAULT_FETCH_MAX_REDIRECTS = 3;
|
||||
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
||||
const DEFAULT_ERROR_MAX_BYTES = 64_000;
|
||||
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||
const DEFAULT_FETCH_USER_AGENT =
|
||||
@@ -108,6 +112,18 @@ function resolveFetchMaxCharsCap(fetch?: WebFetchConfig): number {
|
||||
return Math.max(100, Math.floor(raw));
|
||||
}
|
||||
|
||||
function resolveFetchMaxResponseBytes(fetch?: WebFetchConfig): number {
|
||||
const raw =
|
||||
fetch && "maxResponseBytes" in fetch && typeof fetch.maxResponseBytes === "number"
|
||||
? fetch.maxResponseBytes
|
||||
: undefined;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
|
||||
return DEFAULT_FETCH_MAX_RESPONSE_BYTES;
|
||||
}
|
||||
const value = Math.floor(raw);
|
||||
return Math.min(FETCH_MAX_RESPONSE_BYTES_MAX, Math.max(FETCH_MAX_RESPONSE_BYTES_MIN, value));
|
||||
}
|
||||
|
||||
function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig {
|
||||
if (!fetch || typeof fetch !== "object") {
|
||||
return undefined;
|
||||
@@ -409,15 +425,7 @@ export async function fetchFirecrawlContent(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebFetch(params: {
|
||||
url: string;
|
||||
extractMode: ExtractMode;
|
||||
maxChars: number;
|
||||
maxRedirects: number;
|
||||
timeoutSeconds: number;
|
||||
cacheTtlMs: number;
|
||||
userAgent: string;
|
||||
readabilityEnabled: boolean;
|
||||
type FirecrawlRuntimeParams = {
|
||||
firecrawlEnabled: boolean;
|
||||
firecrawlApiKey?: string;
|
||||
firecrawlBaseUrl: string;
|
||||
@@ -426,7 +434,72 @@ async function runWebFetch(params: {
|
||||
firecrawlProxy: "auto" | "basic" | "stealth";
|
||||
firecrawlStoreInCache: boolean;
|
||||
firecrawlTimeoutSeconds: number;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
};
|
||||
|
||||
type WebFetchRuntimeParams = FirecrawlRuntimeParams & {
|
||||
url: string;
|
||||
extractMode: ExtractMode;
|
||||
maxChars: number;
|
||||
maxResponseBytes: number;
|
||||
maxRedirects: number;
|
||||
timeoutSeconds: number;
|
||||
cacheTtlMs: number;
|
||||
userAgent: string;
|
||||
readabilityEnabled: boolean;
|
||||
};
|
||||
|
||||
function toFirecrawlContentParams(
|
||||
params: FirecrawlRuntimeParams & { url: string; extractMode: ExtractMode },
|
||||
): Parameters<typeof fetchFirecrawlContent>[0] | null {
|
||||
if (!params.firecrawlEnabled || !params.firecrawlApiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
url: params.url,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeFetchFirecrawlWebFetchPayload(
|
||||
params: WebFetchRuntimeParams & {
|
||||
urlToFetch: string;
|
||||
finalUrlFallback: string;
|
||||
statusFallback: number;
|
||||
cacheKey: string;
|
||||
tookMs: number;
|
||||
},
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const firecrawlParams = toFirecrawlContentParams({
|
||||
...params,
|
||||
url: params.urlToFetch,
|
||||
extractMode: params.extractMode,
|
||||
});
|
||||
if (!firecrawlParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firecrawl = await fetchFirecrawlContent(firecrawlParams);
|
||||
const payload = buildFirecrawlWebFetchPayload({
|
||||
firecrawl,
|
||||
rawUrl: params.url,
|
||||
finalUrlFallback: params.finalUrlFallback,
|
||||
statusFallback: params.statusFallback,
|
||||
extractMode: params.extractMode,
|
||||
maxChars: params.maxChars,
|
||||
tookMs: params.tookMs,
|
||||
});
|
||||
writeCache(FETCH_CACHE, params.cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string, unknown>> {
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`fetch:${params.url}:${params.extractMode}:${params.maxChars}`,
|
||||
);
|
||||
@@ -477,28 +550,15 @@ async function runWebFetch(params: {
|
||||
if (error instanceof SsrFBlockedError) {
|
||||
throw error;
|
||||
}
|
||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: finalUrl,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
});
|
||||
const payload = buildFirecrawlWebFetchPayload({
|
||||
firecrawl,
|
||||
rawUrl: params.url,
|
||||
finalUrlFallback: finalUrl,
|
||||
statusFallback: 200,
|
||||
extractMode: params.extractMode,
|
||||
maxChars: params.maxChars,
|
||||
tookMs: Date.now() - start,
|
||||
});
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
const payload = await maybeFetchFirecrawlWebFetchPayload({
|
||||
...params,
|
||||
urlToFetch: finalUrl,
|
||||
finalUrlFallback: finalUrl,
|
||||
statusFallback: 200,
|
||||
cacheKey,
|
||||
tookMs: Date.now() - start,
|
||||
});
|
||||
if (payload) {
|
||||
return payload;
|
||||
}
|
||||
throw error;
|
||||
@@ -506,31 +566,19 @@ async function runWebFetch(params: {
|
||||
|
||||
try {
|
||||
if (!res.ok) {
|
||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: params.url,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
});
|
||||
const payload = buildFirecrawlWebFetchPayload({
|
||||
firecrawl,
|
||||
rawUrl: params.url,
|
||||
finalUrlFallback: finalUrl,
|
||||
statusFallback: res.status,
|
||||
extractMode: params.extractMode,
|
||||
maxChars: params.maxChars,
|
||||
tookMs: Date.now() - start,
|
||||
});
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
const payload = await maybeFetchFirecrawlWebFetchPayload({
|
||||
...params,
|
||||
urlToFetch: params.url,
|
||||
finalUrlFallback: finalUrl,
|
||||
statusFallback: res.status,
|
||||
cacheKey,
|
||||
tookMs: Date.now() - start,
|
||||
});
|
||||
if (payload) {
|
||||
return payload;
|
||||
}
|
||||
const rawDetail = await readResponseText(res);
|
||||
const rawDetailResult = await readResponseText(res, { maxBytes: DEFAULT_ERROR_MAX_BYTES });
|
||||
const rawDetail = rawDetailResult.text;
|
||||
const detail = formatWebFetchErrorDetail({
|
||||
detail: rawDetail,
|
||||
contentType: res.headers.get("content-type"),
|
||||
@@ -542,7 +590,11 @@ async function runWebFetch(params: {
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
||||
const normalizedContentType = normalizeContentType(contentType) ?? "application/octet-stream";
|
||||
const body = await readResponseText(res);
|
||||
const bodyResult = await readResponseText(res, { maxBytes: params.maxResponseBytes });
|
||||
const body = bodyResult.text;
|
||||
const responseTruncatedWarning = bodyResult.truncated
|
||||
? `Response body truncated after ${params.maxResponseBytes} bytes.`
|
||||
: undefined;
|
||||
|
||||
let title: string | undefined;
|
||||
let extractor = "raw";
|
||||
@@ -593,6 +645,7 @@ async function runWebFetch(params: {
|
||||
|
||||
const wrapped = wrapWebFetchContent(text, params.maxChars);
|
||||
const wrappedTitle = title ? wrapWebFetchField(title) : undefined;
|
||||
const wrappedWarning = wrapWebFetchField(responseTruncatedWarning);
|
||||
const payload = {
|
||||
url: params.url, // Keep raw for tool chaining
|
||||
finalUrl, // Keep raw
|
||||
@@ -613,6 +666,7 @@ async function runWebFetch(params: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
tookMs: Date.now() - start,
|
||||
text: wrapped.text,
|
||||
warning: wrappedWarning,
|
||||
};
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
@@ -623,33 +677,15 @@ async function runWebFetch(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function tryFirecrawlFallback(params: {
|
||||
url: string;
|
||||
extractMode: ExtractMode;
|
||||
firecrawlEnabled: boolean;
|
||||
firecrawlApiKey?: string;
|
||||
firecrawlBaseUrl: string;
|
||||
firecrawlOnlyMainContent: boolean;
|
||||
firecrawlMaxAgeMs: number;
|
||||
firecrawlProxy: "auto" | "basic" | "stealth";
|
||||
firecrawlStoreInCache: boolean;
|
||||
firecrawlTimeoutSeconds: number;
|
||||
}): Promise<{ text: string; title?: string } | null> {
|
||||
if (!params.firecrawlEnabled || !params.firecrawlApiKey) {
|
||||
async function tryFirecrawlFallback(
|
||||
params: FirecrawlRuntimeParams & { url: string; extractMode: ExtractMode },
|
||||
): Promise<{ text: string; title?: string } | null> {
|
||||
const firecrawlParams = toFirecrawlContentParams(params);
|
||||
if (!firecrawlParams) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: params.url,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
});
|
||||
const firecrawl = await fetchFirecrawlContent(firecrawlParams);
|
||||
return { text: firecrawl.text, title: firecrawl.title };
|
||||
} catch {
|
||||
return null;
|
||||
@@ -695,6 +731,7 @@ export function createWebFetchTool(options?: {
|
||||
const userAgent =
|
||||
(fetch && "userAgent" in fetch && typeof fetch.userAgent === "string" && fetch.userAgent) ||
|
||||
DEFAULT_FETCH_USER_AGENT;
|
||||
const maxResponseBytes = resolveFetchMaxResponseBytes(fetch);
|
||||
return {
|
||||
label: "Web Fetch",
|
||||
name: "web_fetch",
|
||||
@@ -715,6 +752,7 @@ export function createWebFetchTool(options?: {
|
||||
DEFAULT_FETCH_MAX_CHARS,
|
||||
maxCharsCap,
|
||||
),
|
||||
maxResponseBytes,
|
||||
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
|
||||
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
||||
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../../test-utils/env.js";
|
||||
import { __testing } from "./web-search.js";
|
||||
|
||||
function withEnv<T>(env: Record<string, string | undefined>, fn: () => T): T {
|
||||
const prev: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
prev[key] = process.env[key];
|
||||
if (value === undefined) {
|
||||
// Make tests hermetic even on machines with real keys set.
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
inferPerplexityBaseUrlFromApiKey,
|
||||
resolvePerplexityBaseUrl,
|
||||
|
||||
@@ -486,7 +486,8 @@ async function runPerplexitySearch(params: {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readResponseText(res);
|
||||
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
|
||||
const detail = detailResult.text;
|
||||
throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
@@ -535,7 +536,8 @@ async function runGrokSearch(params: {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readResponseText(res);
|
||||
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
|
||||
const detail = detailResult.text;
|
||||
throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
@@ -665,7 +667,8 @@ async function runWebSearch(params: {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readResponseText(res);
|
||||
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
|
||||
const detail = detailResult.text;
|
||||
throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,85 @@ export function withTimeout(signal: AbortSignal | undefined, timeoutMs: number):
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function readResponseText(res: Response): Promise<string> {
|
||||
export type ReadResponseTextResult = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
bytesRead: number;
|
||||
};
|
||||
|
||||
export async function readResponseText(
|
||||
res: Response,
|
||||
options?: { maxBytes?: number },
|
||||
): Promise<ReadResponseTextResult> {
|
||||
const maxBytesRaw = options?.maxBytes;
|
||||
const maxBytes =
|
||||
typeof maxBytesRaw === "number" && Number.isFinite(maxBytesRaw) && maxBytesRaw > 0
|
||||
? Math.floor(maxBytesRaw)
|
||||
: undefined;
|
||||
|
||||
const body = (res as unknown as { body?: unknown }).body;
|
||||
if (
|
||||
maxBytes &&
|
||||
body &&
|
||||
typeof body === "object" &&
|
||||
"getReader" in body &&
|
||||
typeof (body as { getReader: () => unknown }).getReader === "function"
|
||||
) {
|
||||
const reader = (body as ReadableStream<Uint8Array>).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytesRead = 0;
|
||||
let truncated = false;
|
||||
const parts: string[] = [];
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value || value.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk = value;
|
||||
if (bytesRead + chunk.byteLength > maxBytes) {
|
||||
const remaining = Math.max(0, maxBytes - bytesRead);
|
||||
if (remaining <= 0) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
chunk = chunk.subarray(0, remaining);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
bytesRead += chunk.byteLength;
|
||||
parts.push(decoder.decode(chunk, { stream: true }));
|
||||
|
||||
if (truncated || bytesRead >= maxBytes) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: return whatever we decoded so far.
|
||||
} finally {
|
||||
if (truncated) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(decoder.decode());
|
||||
return { text: parts.join(""), truncated, bytesRead };
|
||||
}
|
||||
|
||||
try {
|
||||
return await res.text();
|
||||
const text = await res.text();
|
||||
return { text, truncated: false, bytesRead: text.length };
|
||||
} catch {
|
||||
return "";
|
||||
return { text: "", truncated: false, bytesRead: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { redactIdentifier } from "../logging/redact-identifier.js";
|
||||
import {
|
||||
classifySessionKeyShape,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js";
|
||||
|
||||
export type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type";
|
||||
type AgentIdSource = "explicit" | "session_key" | "default";
|
||||
@@ -84,8 +86,12 @@ export function resolveRunWorkspaceDir(params: {
|
||||
if (typeof requested === "string") {
|
||||
const trimmed = requested.trim();
|
||||
if (trimmed) {
|
||||
const sanitized = sanitizeForPromptLiteral(trimmed);
|
||||
if (sanitized !== trimmed) {
|
||||
logWarn("Control/format characters stripped from workspaceDir (OC-19 hardening).");
|
||||
}
|
||||
return {
|
||||
workspaceDir: resolveUserPath(trimmed),
|
||||
workspaceDir: resolveUserPath(sanitized),
|
||||
usedFallback: false,
|
||||
agentId,
|
||||
agentIdSource,
|
||||
@@ -96,8 +102,12 @@ export function resolveRunWorkspaceDir(params: {
|
||||
const fallbackReason: WorkspaceFallbackReason =
|
||||
requested == null ? "missing" : typeof requested === "string" ? "blank" : "invalid_type";
|
||||
const fallbackWorkspace = resolveAgentWorkspaceDir(params.config ?? {}, agentId);
|
||||
const sanitizedFallback = sanitizeForPromptLiteral(fallbackWorkspace);
|
||||
if (sanitizedFallback !== fallbackWorkspace) {
|
||||
logWarn("Control/format characters stripped from fallback workspaceDir (OC-19 hardening).");
|
||||
}
|
||||
return {
|
||||
workspaceDir: resolveUserPath(fallbackWorkspace),
|
||||
workspaceDir: resolveUserPath(sanitizedFallback),
|
||||
usedFallback: true,
|
||||
fallbackReason,
|
||||
agentId,
|
||||
|
||||
@@ -127,7 +127,10 @@ describe("group intro prompts", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toContain('"channel": "discord"');
|
||||
expect(extraSystemPrompt).toContain(
|
||||
`You are replying inside a Discord group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
`You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`,
|
||||
);
|
||||
expect(extraSystemPrompt).toContain(
|
||||
`Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -158,8 +161,12 @@ describe("group intro prompts", () => {
|
||||
const extraSystemPrompt =
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toContain('"channel": "whatsapp"');
|
||||
expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`);
|
||||
expect(extraSystemPrompt).toContain(
|
||||
`You are replying inside a WhatsApp group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
`WhatsApp IDs: SenderId is the participant JID (group participant id).`,
|
||||
);
|
||||
expect(extraSystemPrompt).toContain(
|
||||
`Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -190,8 +197,9 @@ describe("group intro prompts", () => {
|
||||
const extraSystemPrompt =
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toContain('"channel": "telegram"');
|
||||
expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`);
|
||||
expect(extraSystemPrompt).toContain(
|
||||
`You are replying inside a Telegram group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
`Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { buildThreadingToolContext } from "./agent-runner-utils.js";
|
||||
|
||||
describe("buildThreadingToolContext", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
it("uses conversation id for WhatsApp", () => {
|
||||
const sessionCtx = {
|
||||
Provider: "whatsapp",
|
||||
From: "123@g.us",
|
||||
To: "+15550001",
|
||||
} as TemplateContext;
|
||||
|
||||
const result = buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: cfg,
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(result.currentChannelId).toBe("123@g.us");
|
||||
});
|
||||
|
||||
it("falls back to To for WhatsApp when From is missing", () => {
|
||||
const sessionCtx = {
|
||||
Provider: "whatsapp",
|
||||
To: "+15550001",
|
||||
} as TemplateContext;
|
||||
|
||||
const result = buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: cfg,
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(result.currentChannelId).toBe("+15550001");
|
||||
});
|
||||
|
||||
it("uses the recipient id for other channels", () => {
|
||||
const sessionCtx = {
|
||||
Provider: "telegram",
|
||||
From: "user:42",
|
||||
To: "chat:99",
|
||||
} as TemplateContext;
|
||||
|
||||
const result = buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: cfg,
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(result.currentChannelId).toBe("chat:99");
|
||||
});
|
||||
|
||||
it("uses the sender handle for iMessage direct chats", () => {
|
||||
const sessionCtx = {
|
||||
Provider: "imessage",
|
||||
ChatType: "direct",
|
||||
From: "imessage:+15550001",
|
||||
To: "chat_id:12",
|
||||
} as TemplateContext;
|
||||
|
||||
const result = buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: cfg,
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(result.currentChannelId).toBe("imessage:+15550001");
|
||||
});
|
||||
|
||||
it("uses chat_id for iMessage groups", () => {
|
||||
const sessionCtx = {
|
||||
Provider: "imessage",
|
||||
ChatType: "group",
|
||||
From: "imessage:group:7",
|
||||
To: "chat_id:7",
|
||||
} as TemplateContext;
|
||||
|
||||
const result = buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: cfg,
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(result.currentChannelId).toBe("chat_id:7");
|
||||
});
|
||||
|
||||
it("prefers MessageThreadId for Slack tool threading", () => {
|
||||
const sessionCtx = {
|
||||
Provider: "slack",
|
||||
To: "channel:C1",
|
||||
MessageThreadId: "123.456",
|
||||
} as TemplateContext;
|
||||
|
||||
const result = buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig,
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(result.currentChannelId).toBe("C1");
|
||||
expect(result.currentThreadTs).toBe("123.456");
|
||||
});
|
||||
});
|
||||
@@ -1,583 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as sessions from "../../config/sessions.js";
|
||||
import {
|
||||
createMinimalRun,
|
||||
getRunEmbeddedPiAgentMock,
|
||||
installRunReplyAgentTypingHeartbeatTestHooks,
|
||||
} from "./agent-runner.heartbeat-typing.test-harness.js";
|
||||
|
||||
type AgentRunParams = {
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
onAssistantMessageStart?: () => Promise<void> | void;
|
||||
onReasoningStream?: (payload: { text?: string }) => Promise<void> | void;
|
||||
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
};
|
||||
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
|
||||
let fixtureRoot = "";
|
||||
let caseId = 0;
|
||||
|
||||
type StateEnvSnapshot = {
|
||||
OPENCLAW_STATE_DIR: string | undefined;
|
||||
};
|
||||
|
||||
function snapshotStateEnv(): StateEnvSnapshot {
|
||||
return { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR };
|
||||
}
|
||||
|
||||
function restoreStateEnv(snapshot: StateEnvSnapshot) {
|
||||
if (snapshot.OPENCLAW_STATE_DIR === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = snapshot.OPENCLAW_STATE_DIR;
|
||||
}
|
||||
}
|
||||
|
||||
async function withTempStateDir<T>(fn: (stateDir: string) => Promise<T>): Promise<T> {
|
||||
const stateDir = path.join(fixtureRoot, `case-${++caseId}`);
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
const envSnapshot = snapshotStateEnv();
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
try {
|
||||
return await fn(stateDir);
|
||||
} finally {
|
||||
restoreStateEnv(envSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCorruptGeminiSessionFixture(params: {
|
||||
stateDir: string;
|
||||
sessionId: string;
|
||||
persistStore: boolean;
|
||||
}) {
|
||||
const storePath = path.join(params.stateDir, "sessions", "sessions.json");
|
||||
const sessionEntry = { sessionId: params.sessionId, updatedAt: Date.now() };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
if (params.persistStore) {
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||
}
|
||||
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(params.sessionId);
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "bad", "utf-8");
|
||||
|
||||
return { storePath, sessionEntry, sessionStore, transcriptPath };
|
||||
}
|
||||
|
||||
describe("runReplyAgent typing (heartbeat)", () => {
|
||||
installRunReplyAgentTypingHeartbeatTestHooks();
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-typing-heartbeat-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (fixtureRoot) {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
});
|
||||
|
||||
it("signals typing for normal runs", async () => {
|
||||
const onPartialReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: false, onPartialReply },
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(onPartialReply).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hi");
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals typing even without consumer partial handler", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hi");
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never signals typing for heartbeat runs", async () => {
|
||||
const onPartialReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: true, onPartialReply },
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(onPartialReply).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses partial streaming for NO_REPLY", async () => {
|
||||
const onPartialReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onPartialReply?.({ text: "NO_REPLY" });
|
||||
return { payloads: [{ text: "NO_REPLY" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: false, onPartialReply },
|
||||
typingMode: "message",
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not start typing on assistant message start without prior text in message mode", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onAssistantMessageStart?.();
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts typing from reasoning stream in thinking mode", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onReasoningStream?.({ text: "Reasoning:\n_step_" });
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "thinking",
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses typing in never mode", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "never",
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals typing on normalized block replies", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onBlockReply?.({ text: "\n\nchunk", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
blockStreamingEnabled: true,
|
||||
opts: { onBlockReply },
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("chunk");
|
||||
expect(onBlockReply).toHaveBeenCalled();
|
||||
const [blockPayload, blockOpts] = onBlockReply.mock.calls[0] ?? [];
|
||||
expect(blockPayload).toMatchObject({ text: "chunk", audioAsVoice: false });
|
||||
expect(blockOpts).toMatchObject({
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
timeoutMs: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("signals typing on tool results", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onToolResult?.({ text: "tooling", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
opts: { onToolResult },
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling");
|
||||
expect(onToolResult).toHaveBeenCalledWith({
|
||||
text: "tooling",
|
||||
mediaUrls: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips typing for silent tool results", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
opts: { onToolResult },
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
expect(onToolResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces auto-compaction in verbose mode and tracks count", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const sessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
const payloads = res as { text?: string }[];
|
||||
expect(payloads[0]?.text).toContain("Auto-compaction complete");
|
||||
expect(payloads[0]?.text).toContain("count 1");
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("retries after compaction failure by resetting the session", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const sessionId = "session";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
|
||||
throw new Error(
|
||||
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||
);
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
expect(payload).toMatchObject({
|
||||
text: expect.stringContaining("Context limit exceeded during compaction"),
|
||||
});
|
||||
expect(payload.text?.toLowerCase()).toContain("reset");
|
||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
it("retries after context overflow payload by resetting the session", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const sessionId = "session";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
|
||||
payloads: [{ text: "Context overflow: prompt too large", isError: true }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
error: {
|
||||
kind: "context_overflow",
|
||||
message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
expect(payload).toMatchObject({
|
||||
text: expect.stringContaining("Context limit exceeded"),
|
||||
});
|
||||
expect(payload.text?.toLowerCase()).toContain("reset");
|
||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets the session after role ordering payloads", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const sessionId = "session";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
|
||||
payloads: [{ text: "Message ordering conflict - please try again.", isError: true }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
error: {
|
||||
kind: "role_ordering",
|
||||
message: 'messages: roles must alternate between "user" and "assistant"',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
expect(payload).toMatchObject({
|
||||
text: expect.stringContaining("Message ordering conflict"),
|
||||
});
|
||||
expect(payload.text?.toLowerCase()).toContain("reset");
|
||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||
await expect(fs.access(transcriptPath)).rejects.toBeDefined();
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets corrupted Gemini sessions and deletes transcripts", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const { storePath, sessionEntry, sessionStore, transcriptPath } =
|
||||
await writeCorruptGeminiSessionFixture({
|
||||
stateDir,
|
||||
sessionId: "session-corrupt",
|
||||
persistStore: true,
|
||||
});
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
|
||||
throw new Error(
|
||||
"function call turn comes immediately after a user turn or after a function response turn",
|
||||
);
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
expect(res).toMatchObject({
|
||||
text: expect.stringContaining("Session history was corrupted"),
|
||||
});
|
||||
expect(sessionStore.main).toBeUndefined();
|
||||
await expect(fs.access(transcriptPath)).rejects.toThrow();
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(persisted.main).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps sessions intact on other errors", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const sessionId = "session-ok";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now() };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
|
||||
throw new Error("INVALID_ARGUMENT: some other failure");
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
expect(res).toMatchObject({
|
||||
text: expect.stringContaining("Agent failed before reply"),
|
||||
});
|
||||
expect(sessionStore.main).toBeDefined();
|
||||
await expect(fs.access(transcriptPath)).resolves.toBeUndefined();
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(persisted.main).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("still replies even if session reset fails to persist", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const saveSpy = vi
|
||||
.spyOn(sessions, "saveSessionStore")
|
||||
.mockRejectedValueOnce(new Error("boom"));
|
||||
try {
|
||||
const { storePath, sessionEntry, sessionStore, transcriptPath } =
|
||||
await writeCorruptGeminiSessionFixture({
|
||||
stateDir,
|
||||
sessionId: "session-corrupt",
|
||||
persistStore: false,
|
||||
});
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
|
||||
throw new Error(
|
||||
"function call turn comes immediately after a user turn or after a function response turn",
|
||||
);
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
expect(res).toMatchObject({
|
||||
text: expect.stringContaining("Session history was corrupted"),
|
||||
});
|
||||
expect(sessionStore.main).toBeUndefined();
|
||||
await expect(fs.access(transcriptPath)).rejects.toThrow();
|
||||
} finally {
|
||||
saveSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("returns friendly message for role ordering errors thrown as exceptions", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
|
||||
throw new Error("400 Incorrect role information");
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({});
|
||||
const res = await run();
|
||||
|
||||
expect(res).toMatchObject({
|
||||
text: expect.stringContaining("Message ordering conflict"),
|
||||
});
|
||||
expect(res).toMatchObject({
|
||||
text: expect.not.stringContaining("400"),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns friendly message for 'roles must alternate' errors thrown as exceptions", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
|
||||
throw new Error('messages: roles must alternate between "user" and "assistant"');
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({});
|
||||
const res = await run();
|
||||
|
||||
expect(res).toMatchObject({
|
||||
text: expect.stringContaining("Message ordering conflict"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites Bun socket errors into friendly text", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
|
||||
payloads: [
|
||||
{
|
||||
text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
meta: {},
|
||||
}));
|
||||
|
||||
const { run } = createMinimalRun();
|
||||
const res = await run();
|
||||
const payloads = Array.isArray(res) ? res : res ? [res] : [];
|
||||
expect(payloads.length).toBe(1);
|
||||
expect(payloads[0]?.text).toContain("LLM connection failed");
|
||||
expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly");
|
||||
expect(payloads[0]?.text).toContain("```");
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { beforeAll, beforeEach, vi } from "vitest";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { GetReplyOptions } from "../types.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
type AnyMock = any;
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
runEmbeddedPiAgentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let runReplyAgentPromise:
|
||||
| Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]>
|
||||
| undefined;
|
||||
|
||||
async function getRunReplyAgent() {
|
||||
if (!runReplyAgentPromise) {
|
||||
runReplyAgentPromise = import("./agent-runner.js").then((m) => m.runReplyAgent);
|
||||
}
|
||||
return await runReplyAgentPromise;
|
||||
}
|
||||
|
||||
export function getRunEmbeddedPiAgentMock(): AnyMock {
|
||||
return state.runEmbeddedPiAgentMock;
|
||||
}
|
||||
|
||||
export function installRunReplyAgentTypingHeartbeatTestHooks() {
|
||||
beforeAll(async () => {
|
||||
// Avoid attributing the initial agent-runner import cost to the first test case.
|
||||
await getRunReplyAgent();
|
||||
});
|
||||
beforeEach(() => {
|
||||
state.runEmbeddedPiAgentMock.mockReset();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHarnessMocks() {
|
||||
const { loadAgentRunnerHarnessMockBundle } = await import("./agent-runner.test-harness.mocks.js");
|
||||
return await loadAgentRunnerHarnessMockBundle(state);
|
||||
}
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", async () => {
|
||||
return (await loadHarnessMocks()).modelFallback;
|
||||
});
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", async () => {
|
||||
return (await loadHarnessMocks()).embeddedPi;
|
||||
});
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
return (await loadHarnessMocks()).queue;
|
||||
});
|
||||
|
||||
export function createMinimalRun(params?: {
|
||||
opts?: GetReplyOptions;
|
||||
resolvedVerboseLevel?: "off" | "on";
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
typingMode?: TypingMode;
|
||||
blockStreamingEnabled?: boolean;
|
||||
}) {
|
||||
const typing = createMockTypingController();
|
||||
const opts = params?.opts;
|
||||
const sessionCtx = {
|
||||
Provider: "whatsapp",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const sessionKey = params?.sessionKey ?? "main";
|
||||
const followupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey,
|
||||
messageProvider: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: params?.resolvedVerboseLevel ?? "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as unknown as FollowupRun;
|
||||
|
||||
return {
|
||||
typing,
|
||||
opts,
|
||||
run: async () => {
|
||||
const runReplyAgent = await getRunReplyAgent();
|
||||
return runReplyAgent({
|
||||
commandBody: "hello",
|
||||
followupRun,
|
||||
queueKey: "main",
|
||||
resolvedQueue,
|
||||
shouldSteer: false,
|
||||
shouldFollowup: false,
|
||||
isActive: false,
|
||||
isStreaming: false,
|
||||
opts,
|
||||
typing,
|
||||
sessionEntry: params?.sessionEntry,
|
||||
sessionStore: params?.sessionStore,
|
||||
sessionKey,
|
||||
storePath: params?.storePath,
|
||||
sessionCtx,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: params?.blockStreamingEnabled ?? false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
shouldInjectGroupIntro: false,
|
||||
typingMode: params?.typingMode ?? "instant",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createBaseRun,
|
||||
getRunCliAgentMock,
|
||||
getRunEmbeddedPiAgentMock,
|
||||
seedSessionStore,
|
||||
type EmbeddedRunParams,
|
||||
} from "./agent-runner.memory-flush.test-harness.js";
|
||||
import { DEFAULT_MEMORY_FLUSH_PROMPT } from "./memory-flush.js";
|
||||
|
||||
let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent;
|
||||
|
||||
let fixtureRoot = "";
|
||||
let caseId = 0;
|
||||
|
||||
async function withTempStore<T>(fn: (storePath: string) => Promise<T>): Promise<T> {
|
||||
const dir = path.join(fixtureRoot, `case-${++caseId}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return await fn(path.join(dir, "sessions.json"));
|
||||
}
|
||||
|
||||
async function runReplyAgentWithBase(params: {
|
||||
baseRun: ReturnType<typeof createBaseRun>;
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
sessionEntry: Record<string, unknown>;
|
||||
commandBody: string;
|
||||
typingMode?: "instant";
|
||||
}): Promise<void> {
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = params.baseRun;
|
||||
await runReplyAgent({
|
||||
commandBody: params.commandBody,
|
||||
followupRun,
|
||||
queueKey: params.sessionKey,
|
||||
resolvedQueue,
|
||||
shouldSteer: false,
|
||||
shouldFollowup: false,
|
||||
isActive: false,
|
||||
isStreaming: false,
|
||||
typing,
|
||||
sessionCtx,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: { [params.sessionKey]: params.sessionEntry },
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
agentCfgContextTokens: 100_000,
|
||||
resolvedVerboseLevel: "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
shouldInjectGroupIntro: false,
|
||||
typingMode: params.typingMode ?? "instant",
|
||||
});
|
||||
}
|
||||
|
||||
async function expectMemoryFlushSkippedWithWorkspaceAccess(
|
||||
workspaceAccess: "ro" | "none",
|
||||
): Promise<void> {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
|
||||
await withTempStore(async (storePath) => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 80_000,
|
||||
compactionCount: 1,
|
||||
};
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const baseRun = createBaseRun({
|
||||
storePath,
|
||||
sessionEntry,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", workspaceAccess },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runReplyAgentWithBase({
|
||||
baseRun,
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
commandBody: "hello",
|
||||
});
|
||||
|
||||
expect(calls.map((call) => call.prompt)).toEqual(["hello"]);
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(stored[sessionKey].memoryFlushAt).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-flush-"));
|
||||
({ runReplyAgent } = await import("./agent-runner.js"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (fixtureRoot) {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("runReplyAgent memory flush", () => {
|
||||
it("skips memory flush for CLI providers", async () => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
const runCliAgentMock = getRunCliAgentMock();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
runCliAgentMock.mockReset();
|
||||
|
||||
await withTempStore(async (storePath) => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 80_000,
|
||||
compactionCount: 1,
|
||||
};
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementation(async () => ({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
}));
|
||||
runCliAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
});
|
||||
|
||||
const baseRun = createBaseRun({
|
||||
storePath,
|
||||
sessionEntry,
|
||||
runOverrides: { provider: "codex-cli" },
|
||||
});
|
||||
|
||||
await runReplyAgentWithBase({
|
||||
baseRun,
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
commandBody: "hello",
|
||||
});
|
||||
|
||||
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
||||
const call = runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined;
|
||||
expect(call?.prompt).toBe("hello");
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured prompts for memory flush runs", async () => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
|
||||
await withTempStore(async (storePath) => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 80_000,
|
||||
compactionCount: 1,
|
||||
};
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<EmbeddedRunParams> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push(params);
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const baseRun = createBaseRun({
|
||||
storePath,
|
||||
sessionEntry,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
memoryFlush: {
|
||||
prompt: "Write notes.",
|
||||
systemPrompt: "Flush memory now.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runOverrides: { extraSystemPrompt: "extra system" },
|
||||
});
|
||||
|
||||
await runReplyAgentWithBase({
|
||||
baseRun,
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
commandBody: "hello",
|
||||
});
|
||||
|
||||
const flushCall = calls[0];
|
||||
expect(flushCall?.prompt).toContain("Write notes.");
|
||||
expect(flushCall?.prompt).toContain("NO_REPLY");
|
||||
expect(flushCall?.extraSystemPrompt).toContain("extra system");
|
||||
expect(flushCall?.extraSystemPrompt).toContain("Flush memory now.");
|
||||
expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY");
|
||||
expect(calls[1]?.prompt).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
it("runs a memory flush turn and updates session metadata", async () => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
|
||||
await withTempStore(async (storePath) => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 80_000,
|
||||
compactionCount: 1,
|
||||
};
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const baseRun = createBaseRun({
|
||||
storePath,
|
||||
sessionEntry,
|
||||
});
|
||||
|
||||
await runReplyAgentWithBase({
|
||||
baseRun,
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
commandBody: "hello",
|
||||
});
|
||||
|
||||
expect(calls.map((call) => call.prompt)).toEqual([DEFAULT_MEMORY_FLUSH_PROMPT, "hello"]);
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number");
|
||||
expect(stored[sessionKey].memoryFlushCompactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips memory flush when disabled in config", async () => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
|
||||
await withTempStore(async (storePath) => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 80_000,
|
||||
compactionCount: 1,
|
||||
};
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementation(async () => ({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
}));
|
||||
|
||||
const baseRun = createBaseRun({
|
||||
storePath,
|
||||
sessionEntry,
|
||||
config: { agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } } },
|
||||
});
|
||||
|
||||
await runReplyAgentWithBase({
|
||||
baseRun,
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
commandBody: "hello",
|
||||
});
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined;
|
||||
expect(call?.prompt).toBe("hello");
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(stored[sessionKey].memoryFlushAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips memory flush after a prior flush in the same compaction cycle", async () => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
|
||||
await withTempStore(async (storePath) => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 80_000,
|
||||
compactionCount: 2,
|
||||
memoryFlushCompactionCount: 2,
|
||||
};
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const baseRun = createBaseRun({
|
||||
storePath,
|
||||
sessionEntry,
|
||||
});
|
||||
|
||||
await runReplyAgentWithBase({
|
||||
baseRun,
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
commandBody: "hello",
|
||||
});
|
||||
|
||||
expect(calls.map((call) => call.prompt)).toEqual(["hello"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips memory flush when the sandbox workspace is read-only", async () => {
|
||||
await expectMemoryFlushSkippedWithWorkspaceAccess("ro");
|
||||
});
|
||||
|
||||
it("skips memory flush when the sandbox workspace is none", async () => {
|
||||
await expectMemoryFlushSkippedWithWorkspaceAccess("none");
|
||||
});
|
||||
|
||||
it("increments compaction count when flush compaction completes", async () => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
|
||||
await withTempStore(async (storePath) => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 80_000,
|
||||
compactionCount: 1,
|
||||
};
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return {
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
|
||||
};
|
||||
});
|
||||
|
||||
const baseRun = createBaseRun({
|
||||
storePath,
|
||||
sessionEntry,
|
||||
});
|
||||
|
||||
await runReplyAgentWithBase({
|
||||
baseRun,
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
commandBody: "hello",
|
||||
});
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(stored[sessionKey].compactionCount).toBe(2);
|
||||
expect(stored[sessionKey].memoryFlushCompactionCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { vi } from "vitest";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
type AnyMock = any;
|
||||
|
||||
type EmbeddedRunParams = {
|
||||
prompt?: string;
|
||||
extraSystemPrompt?: string;
|
||||
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
runEmbeddedPiAgentMock: vi.fn(),
|
||||
runCliAgentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
export function getRunEmbeddedPiAgentMock(): AnyMock {
|
||||
return state.runEmbeddedPiAgentMock;
|
||||
}
|
||||
|
||||
export function getRunCliAgentMock(): AnyMock {
|
||||
return state.runCliAgentMock;
|
||||
}
|
||||
|
||||
export type { EmbeddedRunParams };
|
||||
|
||||
async function loadHarnessMocks() {
|
||||
const { loadAgentRunnerHarnessMockBundle } = await import("./agent-runner.test-harness.mocks.js");
|
||||
return await loadAgentRunnerHarnessMockBundle(state);
|
||||
}
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", async () => {
|
||||
return (await loadHarnessMocks()).modelFallback;
|
||||
});
|
||||
|
||||
vi.mock("../../agents/cli-runner.js", () => ({
|
||||
runCliAgent: (params: unknown) => state.runCliAgentMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", async () => {
|
||||
return (await loadHarnessMocks()).embeddedPi;
|
||||
});
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
return (await loadHarnessMocks()).queue;
|
||||
});
|
||||
|
||||
export async function seedSessionStore(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
entry: Record<string, unknown>;
|
||||
}) {
|
||||
await fs.mkdir(path.dirname(params.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
params.storePath,
|
||||
JSON.stringify({ [params.sessionKey]: params.entry }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
export function createBaseRun(params: {
|
||||
storePath: string;
|
||||
sessionEntry: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
runOverrides?: Partial<FollowupRun["run"]>;
|
||||
}) {
|
||||
const typing = createMockTypingController();
|
||||
const sessionCtx = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingTo: "+15550001111",
|
||||
AccountId: "primary",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const followupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
agentId: "main",
|
||||
agentDir: "/tmp/agent",
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: params.config ?? {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as unknown as FollowupRun;
|
||||
const run = {
|
||||
...followupRun.run,
|
||||
...params.runOverrides,
|
||||
config: params.config ?? followupRun.run.config,
|
||||
};
|
||||
|
||||
return {
|
||||
typing,
|
||||
sessionCtx,
|
||||
resolvedQueue,
|
||||
followupRun: { ...followupRun, run },
|
||||
};
|
||||
}
|
||||
1175
src/auto-reply/reply/agent-runner.runreplyagent.test.ts
Normal file
1175
src/auto-reply/reply/agent-runner.runreplyagent.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export type AgentRunnerEmbeddedState = {
|
||||
runEmbeddedPiAgentMock: (params: unknown) => unknown;
|
||||
};
|
||||
|
||||
export function modelFallbackMockFactory(): Record<string, unknown> {
|
||||
return {
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function embeddedPiMockFactory(state: AgentRunnerEmbeddedState): Record<string, unknown> {
|
||||
return {
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params),
|
||||
};
|
||||
}
|
||||
|
||||
export async function queueMockFactory(): Promise<Record<string, unknown>> {
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
scheduleFollowupDrain: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadAgentRunnerHarnessMockBundle(state: AgentRunnerEmbeddedState): Promise<{
|
||||
modelFallback: Record<string, unknown>;
|
||||
embeddedPi: Record<string, unknown>;
|
||||
queue: Record<string, unknown>;
|
||||
}> {
|
||||
return {
|
||||
modelFallback: modelFallbackMockFactory(),
|
||||
embeddedPi: embeddedPiMockFactory(state),
|
||||
queue: await queueMockFactory(),
|
||||
};
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { handleCommands } from "./commands.js";
|
||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
||||
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("/approve command", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects invalid usage", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams("/approve", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Usage: /approve");
|
||||
});
|
||||
|
||||
it("submits approval", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams("/approve abc allow-once", cfg, { SenderId: "123" });
|
||||
|
||||
const mockCallGateway = vi.mocked(callGateway);
|
||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects gateway clients without approvals scope", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
const mockCallGateway = vi.mocked(callGateway);
|
||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("requires operator.approvals");
|
||||
expect(mockCallGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows gateway clients with approvals scope", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.approvals"],
|
||||
});
|
||||
|
||||
const mockCallGateway = vi.mocked(callGateway);
|
||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows gateway clients with admin scope", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
const mockCallGateway = vi.mocked(callGateway);
|
||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js";
|
||||
import { handleCompactCommand } from "./commands-compact.js";
|
||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn(),
|
||||
compactEmbeddedPiSession: vi.fn(),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./session-updates.js", () => ({
|
||||
incrementCompactionCount: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("/compact command", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when command is not /compact", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams("/status", cfg);
|
||||
|
||||
const result = await handleCompactCommand(
|
||||
{
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unauthorized /compact commands", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams("/compact", cfg);
|
||||
|
||||
const result = await handleCompactCommand(
|
||||
{
|
||||
...params,
|
||||
command: {
|
||||
...params.command,
|
||||
isAuthorizedSender: false,
|
||||
senderId: "unauthorized",
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ shouldContinue: false });
|
||||
expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes manual compaction with explicit trigger and context metadata", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: "/tmp/openclaw-session-store.json" },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams("/compact: focus on decisions", cfg, {
|
||||
From: "+15550001",
|
||||
To: "+15550002",
|
||||
});
|
||||
vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
compacted: false,
|
||||
});
|
||||
|
||||
const result = await handleCompactCommand(
|
||||
{
|
||||
...params,
|
||||
sessionEntry: {
|
||||
sessionId: "session-1",
|
||||
groupId: "group-1",
|
||||
groupChannel: "#general",
|
||||
space: "workspace-1",
|
||||
spawnedBy: "agent:main:parent",
|
||||
totalTokens: 12345,
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
trigger: "manual",
|
||||
customInstructions: "focus on decisions",
|
||||
messageChannel: "whatsapp",
|
||||
groupId: "group-1",
|
||||
groupChannel: "#general",
|
||||
groupSpace: "workspace-1",
|
||||
spawnedBy: "agent:main:parent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCommandsPaginationKeyboard } from "./commands-info.js";
|
||||
|
||||
describe("buildCommandsPaginationKeyboard", () => {
|
||||
it("adds agent id to callback data when provided", () => {
|
||||
const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main");
|
||||
expect(keyboard[0]).toEqual([
|
||||
{ text: "◀ Prev", callback_data: "commands_page_1:agent-main" },
|
||||
{ text: "2/3", callback_data: "commands_page_noop:agent-main" },
|
||||
{ text: "Next ▶", callback_data: "commands_page_3:agent-main" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { extractMessageText } from "./commands-subagents.js";
|
||||
import { handleCommands } from "./commands.js";
|
||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
import { parseDebugCommand } from "./debug-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 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDebugCommand", () => {
|
||||
it("parses show/reset", () => {
|
||||
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
|
||||
it("parses unset", () => {
|
||||
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText", () => {
|
||||
it("preserves user text that looks like tool call markers", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||
});
|
||||
|
||||
it("sanitizes assistant tool call markers", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toBe("Here ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands /config configWrites gating", () => {
|
||||
it("blocks /config set when channel config writes are disabled", async () => {
|
||||
const cfg = {
|
||||
commands: { config: true, text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildCommandTestParams('/config set messages.ackReaction=":)"', cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||
});
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||
"../../pairing/pairing-store.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||
"../../channels/plugins/pairing.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listPairingChannels: () => ["telegram"],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(async () => [
|
||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" },
|
||||
{ provider: "openai", id: "gpt-4.1", name: "GPT-4.1" },
|
||||
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
|
||||
{ provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" },
|
||||
]),
|
||||
}));
|
||||
|
||||
function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
...ctxOverrides,
|
||||
} as MsgContext;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives: parseInlineDirectives(commandBody),
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "telegram",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /allowlist", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists config + store allowFrom entries", async () => {
|
||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/allowlist list dm", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Channel: telegram");
|
||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||
});
|
||||
|
||||
it("adds entries to config and pairing store", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: ["123", "789"],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/allowlist add dm 789", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||
}),
|
||||
);
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
});
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
|
||||
it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: {
|
||||
slack: {
|
||||
allowFrom: ["U111", "U222"],
|
||||
dm: { allowFrom: ["U111", "U222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: {
|
||||
slack: {
|
||||
allowFrom: ["U111", "U222"],
|
||||
dm: { allowFrom: ["U111", "U222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const params = buildParams("/allowlist remove dm U111", cfg, {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
});
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||
expect(written.channels?.slack?.allowFrom).toEqual(["U222"]);
|
||||
expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined();
|
||||
expect(result.reply?.text).toContain("channels.slack.allowFrom");
|
||||
});
|
||||
|
||||
it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["111", "222"],
|
||||
dm: { allowFrom: ["111", "222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["111", "222"],
|
||||
dm: { allowFrom: ["111", "222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const params = buildParams("/allowlist remove dm 111", cfg, {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
});
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||
expect(written.channels?.discord?.allowFrom).toEqual(["222"]);
|
||||
expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined();
|
||||
expect(result.reply?.text).toContain("channels.discord.allowFrom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/models command", () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
|
||||
const params = buildParams("/models", cfg, { Provider: surface, Surface: surface });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Providers:");
|
||||
expect(result.reply?.text).toContain("anthropic");
|
||||
expect(result.reply?.text).toContain("Use: /models <provider>");
|
||||
});
|
||||
|
||||
it("lists providers on telegram (buttons)", async () => {
|
||||
const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toBe("Select a provider:");
|
||||
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
|
||||
?.telegram?.buttons;
|
||||
expect(buttons).toBeDefined();
|
||||
expect(buttons?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lists provider models with pagination hints", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildParams("/models anthropic", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
expect(result.reply?.text).toContain("page 1/");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).toContain("Switch: /model <provider/model>");
|
||||
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||
});
|
||||
|
||||
it("ignores page argument when all flag is present", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
expect(result.reply?.text).toContain("page 1/1");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).not.toContain("Page out of range");
|
||||
});
|
||||
|
||||
it("errors on out-of-range pages", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Page out of range");
|
||||
expect(result.reply?.text).toContain("valid: 1-");
|
||||
});
|
||||
|
||||
it("handles unknown providers", async () => {
|
||||
const params = buildParams("/models not-a-provider", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Unknown provider");
|
||||
expect(result.reply?.text).toContain("Available providers");
|
||||
});
|
||||
|
||||
it("lists configured models outside the curated catalog", async () => {
|
||||
const customCfg = {
|
||||
commands: { text: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "localai/ultra-chat",
|
||||
fallbacks: ["anthropic/claude-opus-4-5"],
|
||||
},
|
||||
imageModel: "visionpro/studio-v1",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
// Use discord surface for text-based output tests
|
||||
const providerList = await handleCommands(
|
||||
buildParams("/models", customCfg, { Surface: "discord" }),
|
||||
);
|
||||
expect(providerList.reply?.text).toContain("localai");
|
||||
expect(providerList.reply?.text).toContain("visionpro");
|
||||
|
||||
const result = await handleCommands(
|
||||
buildParams("/models localai", customCfg, { Surface: "discord" }),
|
||||
);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (localai)");
|
||||
expect(result.reply?.text).toContain("localai/ultra-chat");
|
||||
expect(result.reply?.text).not.toContain("Unknown provider");
|
||||
});
|
||||
});
|
||||
38
src/auto-reply/reply/commands-setunset.ts
Normal file
38
src/auto-reply/reply/commands-setunset.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { parseConfigValue } from "./config-value.js";
|
||||
|
||||
export type SetUnsetParseResult =
|
||||
| { kind: "set"; path: string; value: unknown }
|
||||
| { kind: "unset"; path: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function parseSetUnsetCommand(params: {
|
||||
slash: string;
|
||||
action: "set" | "unset";
|
||||
args: string;
|
||||
}): SetUnsetParseResult {
|
||||
const action = params.action;
|
||||
const args = params.args.trim();
|
||||
if (action === "unset") {
|
||||
if (!args) {
|
||||
return { kind: "error", message: `Usage: ${params.slash} unset path` };
|
||||
}
|
||||
return { kind: "unset", path: args };
|
||||
}
|
||||
if (!args) {
|
||||
return { kind: "error", message: `Usage: ${params.slash} set path=value` };
|
||||
}
|
||||
const eqIndex = args.indexOf("=");
|
||||
if (eqIndex <= 0) {
|
||||
return { kind: "error", message: `Usage: ${params.slash} set path=value` };
|
||||
}
|
||||
const path = args.slice(0, eqIndex).trim();
|
||||
const rawValue = args.slice(eqIndex + 1);
|
||||
if (!path) {
|
||||
return { kind: "error", message: `Usage: ${params.slash} set path=value` };
|
||||
}
|
||||
const parsed = parseConfigValue(rawValue);
|
||||
if (parsed.error) {
|
||||
return { kind: "error", message: parsed.error };
|
||||
}
|
||||
return { kind: "set", path, value: parsed.value };
|
||||
}
|
||||
46
src/auto-reply/reply/commands-slash-parse.ts
Normal file
46
src/auto-reply/reply/commands-slash-parse.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type SlashCommandParseResult =
|
||||
| { kind: "no-match" }
|
||||
| { kind: "empty" }
|
||||
| { kind: "invalid" }
|
||||
| { kind: "parsed"; action: string; args: string };
|
||||
|
||||
export type ParsedSlashCommand =
|
||||
| { ok: true; action: string; args: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
export function parseSlashCommandActionArgs(raw: string, slash: string): SlashCommandParseResult {
|
||||
const trimmed = raw.trim();
|
||||
const slashLower = slash.toLowerCase();
|
||||
if (!trimmed.toLowerCase().startsWith(slashLower)) {
|
||||
return { kind: "no-match" };
|
||||
}
|
||||
const rest = trimmed.slice(slash.length).trim();
|
||||
if (!rest) {
|
||||
return { kind: "empty" };
|
||||
}
|
||||
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) {
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
const action = match[1]?.toLowerCase() ?? "";
|
||||
const args = (match[2] ?? "").trim();
|
||||
return { kind: "parsed", action, args };
|
||||
}
|
||||
|
||||
export function parseSlashCommandOrNull(
|
||||
raw: string,
|
||||
slash: string,
|
||||
opts: { invalidMessage: string; defaultAction?: string },
|
||||
): ParsedSlashCommand | null {
|
||||
const parsed = parseSlashCommandActionArgs(raw, slash);
|
||||
if (parsed.kind === "no-match") {
|
||||
return null;
|
||||
}
|
||||
if (parsed.kind === "invalid") {
|
||||
return { ok: false, message: opts.invalidMessage };
|
||||
}
|
||||
if (parsed.kind === "empty") {
|
||||
return { ok: true, action: opts.defaultAction ?? "show", args: "" };
|
||||
}
|
||||
return { ok: true, action: parsed.action, args: parsed.args };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import {
|
||||
@@ -13,14 +13,96 @@ import { updateSessionStore } from "../../config/sessions.js";
|
||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||
import { handleCompactCommand } from "./commands-compact.js";
|
||||
import { buildCommandsPaginationKeyboard } from "./commands-info.js";
|
||||
import { extractMessageText } from "./commands-subagents.js";
|
||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||
"../../pairing/pairing-store.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||
"../../channels/plugins/pairing.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listPairingChannels: () => ["telegram"],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(async () => [
|
||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" },
|
||||
{ provider: "openai", id: "gpt-4.1", name: "GPT-4.1" },
|
||||
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
|
||||
{ provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" },
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => {
|
||||
const resolveEmbeddedSessionLane = (key: string) => {
|
||||
const cleaned = key.trim() || "main";
|
||||
return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`;
|
||||
};
|
||||
return {
|
||||
abortEmbeddedPiRun: vi.fn(),
|
||||
compactEmbeddedPiSession: vi.fn(),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane,
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./session-updates.js", () => ({
|
||||
incrementCompactionCount: vi.fn(),
|
||||
}));
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
import { handleCommands } from "./commands.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
|
||||
// Avoid expensive workspace scans during /context tests.
|
||||
vi.mock("./commands-context-report.js", () => ({
|
||||
@@ -104,6 +186,293 @@ describe("handleCommands gating", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/approve command", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects invalid usage", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Usage: /approve");
|
||||
});
|
||||
|
||||
it("submits approval", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" });
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects gateway clients without approvals scope", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("requires operator.approvals");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows gateway clients with approvals scope", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.approvals"],
|
||||
});
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows gateway clients with admin scope", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/compact command", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when command is not /compact", async () => {
|
||||
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/status", cfg);
|
||||
|
||||
const result = await handleCompactCommand(
|
||||
{
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unauthorized /compact commands", async () => {
|
||||
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/compact", cfg);
|
||||
|
||||
const result = await handleCompactCommand(
|
||||
{
|
||||
...params,
|
||||
command: {
|
||||
...params.command,
|
||||
isAuthorizedSender: false,
|
||||
senderId: "unauthorized",
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ shouldContinue: false });
|
||||
expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes manual compaction with explicit trigger and context metadata", async () => {
|
||||
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: "/tmp/openclaw-session-store.json" },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/compact: focus on decisions", cfg, {
|
||||
From: "+15550001",
|
||||
To: "+15550002",
|
||||
});
|
||||
vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
compacted: false,
|
||||
});
|
||||
|
||||
const result = await handleCompactCommand(
|
||||
{
|
||||
...params,
|
||||
sessionEntry: {
|
||||
sessionId: "session-1",
|
||||
groupId: "group-1",
|
||||
groupChannel: "#general",
|
||||
space: "workspace-1",
|
||||
spawnedBy: "agent:main:parent",
|
||||
totalTokens: 12345,
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
trigger: "manual",
|
||||
customInstructions: "focus on decisions",
|
||||
messageChannel: "whatsapp",
|
||||
groupId: "group-1",
|
||||
groupChannel: "#general",
|
||||
groupSpace: "workspace-1",
|
||||
spawnedBy: "agent:main:parent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCommandsPaginationKeyboard", () => {
|
||||
it("adds agent id to callback data when provided", () => {
|
||||
const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main");
|
||||
expect(keyboard[0]).toEqual([
|
||||
{ text: "◀ Prev", callback_data: "commands_page_1:agent-main" },
|
||||
{ text: "2/3", callback_data: "commands_page_noop:agent-main" },
|
||||
{ text: "Next ▶", callback_data: "commands_page_3:agent-main" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDebugCommand", () => {
|
||||
it("parses show/reset", () => {
|
||||
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
|
||||
it("parses unset", () => {
|
||||
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText", () => {
|
||||
it("preserves user text that looks like tool call markers", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||
});
|
||||
|
||||
it("sanitizes assistant tool call markers", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toBe("Here ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands /config configWrites gating", () => {
|
||||
it("blocks /config set when channel config writes are disabled", async () => {
|
||||
const cfg = {
|
||||
commands: { config: true, text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands bash alias", () => {
|
||||
it("routes !poll through the /bash handler", async () => {
|
||||
resetBashChatCommandForTests();
|
||||
@@ -130,6 +499,289 @@ describe("handleCommands bash alias", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function buildPolicyParams(
|
||||
commandBody: string,
|
||||
cfg: OpenClawConfig,
|
||||
ctxOverrides?: Partial<MsgContext>,
|
||||
) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
...ctxOverrides,
|
||||
} as MsgContext;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives: parseInlineDirectives(commandBody),
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "telegram",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /allowlist", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists config + store allowFrom entries", async () => {
|
||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildPolicyParams("/allowlist list dm", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Channel: telegram");
|
||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||
});
|
||||
|
||||
it("adds entries to config and pairing store", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: ["123", "789"],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildPolicyParams("/allowlist add dm 789", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||
}),
|
||||
);
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
});
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
|
||||
it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: {
|
||||
slack: {
|
||||
allowFrom: ["U111", "U222"],
|
||||
dm: { allowFrom: ["U111", "U222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: {
|
||||
slack: {
|
||||
allowFrom: ["U111", "U222"],
|
||||
dm: { allowFrom: ["U111", "U222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const params = buildPolicyParams("/allowlist remove dm U111", cfg, {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
});
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||
expect(written.channels?.slack?.allowFrom).toEqual(["U222"]);
|
||||
expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined();
|
||||
expect(result.reply?.text).toContain("channels.slack.allowFrom");
|
||||
});
|
||||
|
||||
it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["111", "222"],
|
||||
dm: { allowFrom: ["111", "222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["111", "222"],
|
||||
dm: { allowFrom: ["111", "222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const params = buildPolicyParams("/allowlist remove dm 111", cfg, {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
});
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||
expect(written.channels?.discord?.allowFrom).toEqual(["222"]);
|
||||
expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined();
|
||||
expect(result.reply?.text).toContain("channels.discord.allowFrom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/models command", () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
|
||||
const params = buildPolicyParams("/models", cfg, { Provider: surface, Surface: surface });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Providers:");
|
||||
expect(result.reply?.text).toContain("anthropic");
|
||||
expect(result.reply?.text).toContain("Use: /models <provider>");
|
||||
});
|
||||
|
||||
it("lists providers on telegram (buttons)", async () => {
|
||||
const params = buildPolicyParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toBe("Select a provider:");
|
||||
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
|
||||
?.telegram?.buttons;
|
||||
expect(buttons).toBeDefined();
|
||||
expect(buttons?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lists provider models with pagination hints", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildPolicyParams("/models anthropic", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
expect(result.reply?.text).toContain("page 1/");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).toContain("Switch: /model <provider/model>");
|
||||
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||
});
|
||||
|
||||
it("ignores page argument when all flag is present", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildPolicyParams("/models anthropic 3 all", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
expect(result.reply?.text).toContain("page 1/1");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).not.toContain("Page out of range");
|
||||
});
|
||||
|
||||
it("errors on out-of-range pages", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildPolicyParams("/models anthropic 4", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Page out of range");
|
||||
expect(result.reply?.text).toContain("valid: 1-");
|
||||
});
|
||||
|
||||
it("handles unknown providers", async () => {
|
||||
const params = buildPolicyParams("/models not-a-provider", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Unknown provider");
|
||||
expect(result.reply?.text).toContain("Available providers");
|
||||
});
|
||||
|
||||
it("lists configured models outside the curated catalog", async () => {
|
||||
const customCfg = {
|
||||
commands: { text: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "localai/ultra-chat",
|
||||
fallbacks: ["anthropic/claude-opus-4-5"],
|
||||
},
|
||||
imageModel: "visionpro/studio-v1",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
// Use discord surface for text-based output tests
|
||||
const providerList = await handleCommands(
|
||||
buildPolicyParams("/models", customCfg, { Surface: "discord" }),
|
||||
);
|
||||
expect(providerList.reply?.text).toContain("localai");
|
||||
expect(providerList.reply?.text).toContain("visionpro");
|
||||
|
||||
const result = await handleCommands(
|
||||
buildPolicyParams("/models localai", customCfg, { Surface: "discord" }),
|
||||
);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (localai)");
|
||||
expect(result.reply?.text).toContain("localai/ultra-chat");
|
||||
expect(result.reply?.text).not.toContain("Unknown provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands plugin commands", () => {
|
||||
it("dispatches registered plugin commands", async () => {
|
||||
clearPluginCommands();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseConfigValue } from "./config-value.js";
|
||||
import { parseSetUnsetCommand } from "./commands-setunset.js";
|
||||
import { parseSlashCommandOrNull } from "./commands-slash-parse.js";
|
||||
|
||||
export type ConfigCommand =
|
||||
| { action: "show"; path?: string }
|
||||
@@ -7,60 +8,31 @@ export type ConfigCommand =
|
||||
| { action: "error"; message: string };
|
||||
|
||||
export function parseConfigCommand(raw: string): ConfigCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/config")) {
|
||||
const parsed = parseSlashCommandOrNull(raw, "/config", {
|
||||
invalidMessage: "Invalid /config syntax.",
|
||||
});
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice("/config".length).trim();
|
||||
if (!rest) {
|
||||
return { action: "show" };
|
||||
if (!parsed.ok) {
|
||||
return { action: "error", message: parsed.message };
|
||||
}
|
||||
|
||||
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();
|
||||
const { action, args } = parsed;
|
||||
|
||||
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 "unset":
|
||||
case "set": {
|
||||
if (!args) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /config set path=value",
|
||||
};
|
||||
const parsed = parseSetUnsetCommand({ slash: "/config", action, args });
|
||||
if (parsed.kind === "error") {
|
||||
return { action: "error", message: parsed.message };
|
||||
}
|
||||
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 };
|
||||
return parsed.kind === "set"
|
||||
? { action: "set", path: parsed.path, value: parsed.value }
|
||||
: { action: "unset", path: parsed.path };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseConfigValue } from "./config-value.js";
|
||||
import { parseSetUnsetCommand } from "./commands-setunset.js";
|
||||
import { parseSlashCommandOrNull } from "./commands-slash-parse.js";
|
||||
|
||||
export type DebugCommand =
|
||||
| { action: "show" }
|
||||
@@ -8,60 +9,31 @@ export type DebugCommand =
|
||||
| { action: "error"; message: string };
|
||||
|
||||
export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/debug")) {
|
||||
const parsed = parseSlashCommandOrNull(raw, "/debug", {
|
||||
invalidMessage: "Invalid /debug syntax.",
|
||||
});
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice("/debug".length).trim();
|
||||
if (!rest) {
|
||||
return { action: "show" };
|
||||
if (!parsed.ok) {
|
||||
return { action: "error", message: parsed.message };
|
||||
}
|
||||
|
||||
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) {
|
||||
return { action: "error", message: "Invalid /debug syntax." };
|
||||
}
|
||||
const action = match[1].toLowerCase();
|
||||
const args = (match[2] ?? "").trim();
|
||||
const { action, args } = parsed;
|
||||
|
||||
switch (action) {
|
||||
case "show":
|
||||
return { action: "show" };
|
||||
case "reset":
|
||||
return { action: "reset" };
|
||||
case "unset": {
|
||||
if (!args) {
|
||||
return { action: "error", message: "Usage: /debug unset path" };
|
||||
}
|
||||
return { action: "unset", path: args };
|
||||
}
|
||||
case "unset":
|
||||
case "set": {
|
||||
if (!args) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /debug set path=value",
|
||||
};
|
||||
const parsed = parseSetUnsetCommand({ slash: "/debug", action, args });
|
||||
if (parsed.kind === "error") {
|
||||
return { action: "error", message: parsed.message };
|
||||
}
|
||||
const eqIndex = args.indexOf("=");
|
||||
if (eqIndex <= 0) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /debug set path=value",
|
||||
};
|
||||
}
|
||||
const path = args.slice(0, eqIndex).trim();
|
||||
const rawValue = args.slice(eqIndex + 1);
|
||||
if (!path) {
|
||||
return {
|
||||
action: "error",
|
||||
message: "Usage: /debug set path=value",
|
||||
};
|
||||
}
|
||||
const parsed = parseConfigValue(rawValue);
|
||||
if (parsed.error) {
|
||||
return { action: "error", message: parsed.error };
|
||||
}
|
||||
return { action: "set", path, value: parsed.value };
|
||||
return parsed.kind === "set"
|
||||
? { action: "set", path: parsed.path, value: parsed.value }
|
||||
: { action: "unset", path: parsed.path };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
|
||||
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
||||
import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js";
|
||||
import { isDirectiveOnly } from "./directive-handling.parse.js";
|
||||
|
||||
export async function applyInlineDirectivesFastLane(
|
||||
@@ -48,19 +48,12 @@ export async function applyInlineDirectivesFastLane(
|
||||
}
|
||||
|
||||
const agentCfg = params.agentCfg;
|
||||
const resolvedDefaultThinkLevel =
|
||||
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined) ??
|
||||
(await modelState.resolveDefaultThinkingLevel());
|
||||
const currentThinkLevel = resolvedDefaultThinkLevel;
|
||||
const currentVerboseLevel =
|
||||
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||
const currentReasoningLevel =
|
||||
(sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
const currentElevatedLevel =
|
||||
(sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(agentCfg?.elevatedDefault as ElevatedLevel | undefined);
|
||||
const { currentThinkLevel, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel } =
|
||||
await resolveCurrentDirectiveLevels({
|
||||
sessionEntry,
|
||||
agentCfg,
|
||||
resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(),
|
||||
});
|
||||
|
||||
const directiveAck = await handleDirectiveOnly({
|
||||
cfg,
|
||||
|
||||
41
src/auto-reply/reply/directive-handling.levels.ts
Normal file
41
src/auto-reply/reply/directive-handling.levels.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
|
||||
export async function resolveCurrentDirectiveLevels(params: {
|
||||
sessionEntry?: {
|
||||
thinkingLevel?: unknown;
|
||||
verboseLevel?: unknown;
|
||||
reasoningLevel?: unknown;
|
||||
elevatedLevel?: unknown;
|
||||
};
|
||||
agentCfg?: {
|
||||
thinkingDefault?: unknown;
|
||||
verboseDefault?: unknown;
|
||||
elevatedDefault?: unknown;
|
||||
};
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
}): Promise<{
|
||||
currentThinkLevel: ThinkLevel | undefined;
|
||||
currentVerboseLevel: VerboseLevel | undefined;
|
||||
currentReasoningLevel: ReasoningLevel;
|
||||
currentElevatedLevel: ElevatedLevel | undefined;
|
||||
}> {
|
||||
const resolvedDefaultThinkLevel =
|
||||
(params.sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
(params.agentCfg?.thinkingDefault as ThinkLevel | undefined) ??
|
||||
(await params.resolveDefaultThinkingLevel());
|
||||
const currentThinkLevel = resolvedDefaultThinkLevel;
|
||||
const currentVerboseLevel =
|
||||
(params.sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||
(params.agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||
const currentReasoningLevel =
|
||||
(params.sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
const currentElevatedLevel =
|
||||
(params.sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(params.agentCfg?.elevatedDefault as ElevatedLevel | undefined);
|
||||
return {
|
||||
currentThinkLevel,
|
||||
currentVerboseLevel,
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
};
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseAudioTag } from "./audio-tags.js";
|
||||
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||
import { createReplyReferencePlanner } from "./reply-reference.js";
|
||||
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
||||
|
||||
describe("parseAudioTag", () => {
|
||||
it("detects audio_as_voice and strips the tag", () => {
|
||||
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.hadTag).toBe(true);
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("returns empty output for missing text", () => {
|
||||
const result = parseAudioTag(undefined);
|
||||
expect(result.audioAsVoice).toBe(false);
|
||||
expect(result.hadTag).toBe(false);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("removes tag-only messages", () => {
|
||||
const result = parseAudioTag("[[audio_as_voice]]");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("block reply coalescer", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("coalesces chunks within the idle window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hello" });
|
||||
coalescer.enqueue({ text: "world" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(flushes).toEqual(["Hello world"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("waits until minChars before idle flush", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "short" });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual([]);
|
||||
|
||||
coalescer.enqueue({ text: "message" });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual(["short message"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes each enqueued payload separately when flushOnEnqueue is set", async () => {
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "First paragraph" });
|
||||
coalescer.enqueue({ text: "Second paragraph" });
|
||||
coalescer.enqueue({ text: "Third paragraph" });
|
||||
|
||||
await Promise.resolve();
|
||||
expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("still accumulates when flushOnEnqueue is not set (default)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "First paragraph" });
|
||||
coalescer.enqueue({ text: "Second paragraph" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes short payloads immediately when flushOnEnqueue is set", async () => {
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hi" });
|
||||
await Promise.resolve();
|
||||
expect(flushes).toEqual(["Hi"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("resets char budget per paragraph with flushOnEnqueue", async () => {
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
// Each 20-char payload fits within maxChars=30 individually
|
||||
coalescer.enqueue({ text: "12345678901234567890" });
|
||||
coalescer.enqueue({ text: "abcdefghijklmnopqrst" });
|
||||
|
||||
await Promise.resolve();
|
||||
// Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split.
|
||||
// With flushOnEnqueue, each is sent independently within budget.
|
||||
expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes buffered text before media payloads", () => {
|
||||
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push({
|
||||
text: payload.text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hello" });
|
||||
coalescer.enqueue({ text: "world" });
|
||||
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
||||
void coalescer.flush({ force: true });
|
||||
|
||||
expect(flushes[0].text).toBe("Hello world");
|
||||
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReplyReferencePlanner", () => {
|
||||
it("disables references when mode is off", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "off",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBeUndefined();
|
||||
expect(planner.hasReplied()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses startId once when mode is first", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "first",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("parent");
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
planner.markSent();
|
||||
expect(planner.use()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns startId for every call when mode is all", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "all",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("parent");
|
||||
expect(planner.use()).toBe("parent");
|
||||
});
|
||||
|
||||
it("respects replyToMode off even with existingId", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "off",
|
||||
existingId: "thread-1",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBeUndefined();
|
||||
expect(planner.hasReplied()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses existingId once when mode is first", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "first",
|
||||
existingId: "thread-1",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("thread-1");
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
expect(planner.use()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses existingId on every call when mode is all", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "all",
|
||||
existingId: "thread-1",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("thread-1");
|
||||
expect(planner.use()).toBe("thread-1");
|
||||
});
|
||||
|
||||
it("honors allowReference=false", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
replyToMode: "all",
|
||||
startId: "parent",
|
||||
allowReference: false,
|
||||
});
|
||||
expect(planner.use()).toBeUndefined();
|
||||
expect(planner.hasReplied()).toBe(false);
|
||||
planner.markSent();
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStreamingDirectiveAccumulator", () => {
|
||||
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hello");
|
||||
expect(result?.text).toBe("Hello");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("handles reply tags split across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("current]] Yo");
|
||||
expect(result?.text).toBe("Yo");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates explicit reply ids across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hi");
|
||||
expect(result?.text).toBe("Hi");
|
||||
expect(result?.replyToId).toBe("abc-123");
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user