Compare commits

...

202 Commits

Author SHA1 Message Date
Vincent Koc
c642e1da24 fix(gateway): guard node approval policy writes 2026-06-01 01:52:02 +01:00
Ted Li
6316648bab fix(openai): keep stop-finished tool calls
Preserve silent structured OpenAI-compatible tool calls when providers stream tool_calls but finish with finish_reason stop, while keeping visible-text stop responses and unfinished streams from executing spurious tool calls.

Fixes #88791.

Verification:
- pnpm tsgo:prod
- node scripts/run-vitest.mjs src/llm/providers/openai-completions.test.ts src/agents/openai-transport-stream.test.ts
- loopback OpenAI-compatible SSE proof against createOpenAICompletionsTransportStreamFn
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-05-31 19:41:23 -04:00
Gio Della-Libera
bf777b9af2 fix(doctor): quiet tool policy audits during probes
Keep runtime tool-policy removal audits at the normal info level, but lower diagnostic-only doctor tool-schema probes to debug so expected profile filtering does not clutter normal doctor output.

Also updates current-base test expectations for the Talk custom select and a promise-executor lint rule so the PR remains green on the latest base.

Fixes #87798.

Proof:
- CI https://github.com/openclaw/openclaw/actions/runs/26727664397
- Real behavior proof https://github.com/openclaw/openclaw/actions/runs/26727667473
- Local focused Vitest, broad lint, touched-file format/lint, and autoreview clean.

Co-authored-by: Gio Della-Libera <40915808+giodl73-repo@users.noreply.github.com>
2026-05-31 19:37:13 -04:00
Peter Steinberger
fba9eac7eb fix(google): register Vertex static catalog rows 2026-06-01 00:36:31 +01:00
Peter Steinberger
5965522af5 fix(copilot): preserve Claude 1M capabilities 2026-06-01 00:36:31 +01:00
Peter Steinberger
f18fd2094f fix(agents): match provider-scoped context ids 2026-06-01 00:36:30 +01:00
Peter Steinberger
770ee8eba6 fix(models): refresh provider catalog metadata 2026-06-01 00:36:30 +01:00
Vincent Koc
b891d42f3a refactor: share talk session turn handling 2026-06-01 01:32:06 +02:00
Vincent Koc
705bdcec70 fix(gateway): harden MCP loopback tool schemas 2026-05-31 19:30:02 -04:00
github-actions[bot]
db7aff8843 chore(ui): refresh fa control ui locale 2026-05-31 23:23:45 +00:00
github-actions[bot]
d30329fb0e chore(ui): refresh nl control ui locale 2026-05-31 23:23:37 +00:00
github-actions[bot]
c7f3d60722 chore(ui): refresh vi control ui locale 2026-05-31 23:23:13 +00:00
github-actions[bot]
0ffaeb1273 chore(ui): refresh th control ui locale 2026-05-31 23:23:06 +00:00
github-actions[bot]
c43a571170 chore(ui): refresh pl control ui locale 2026-05-31 23:23:02 +00:00
github-actions[bot]
dd8b9bdcb8 chore(ui): refresh id control ui locale 2026-05-31 23:22:48 +00:00
github-actions[bot]
399f55e511 chore(ui): refresh uk control ui locale 2026-05-31 23:22:29 +00:00
github-actions[bot]
7e654b40b8 chore(ui): refresh tr control ui locale 2026-05-31 23:22:18 +00:00
github-actions[bot]
7b119ec60d chore(ui): refresh it control ui locale 2026-05-31 23:22:15 +00:00
github-actions[bot]
c1fffe1074 chore(ui): refresh ar control ui locale 2026-05-31 23:22:05 +00:00
github-actions[bot]
530f3aaab7 chore(ui): refresh fr control ui locale 2026-05-31 23:21:43 +00:00
github-actions[bot]
3ec1a25de4 chore(ui): refresh ja-JP control ui locale 2026-05-31 23:21:30 +00:00
github-actions[bot]
5a6ec67eb0 chore(ui): refresh es control ui locale 2026-05-31 23:21:27 +00:00
github-actions[bot]
0fdca6974d chore(ui): refresh ko control ui locale 2026-05-31 23:21:24 +00:00
Jerry-Xin
dc344a33fb fix(cron): retire MCP runtimes on isolated cron cleanup
Retire isolated cron session MCP runtimes on timeout and dispose so orphaned MCP servers do not accumulate after cron cleanup. Bound MCP session disposal to 5 seconds and force-close hung transports, including streamable-HTTP DELETE hangs, to prefer gateway availability over unbounded teardown.

Fixes #87821.
PR: #87981.
Proof: latest Real behavior proof check passed after body fix; local autoreview clean with focused cron/gateway/MCP tests covering 108 tests.

Co-authored-by: 忻役 <xinyi@mininglamp.com>
Co-authored-by: Jerry-Xin <jerryxin0@gmail.com>
2026-06-01 00:21:14 +01:00
github-actions[bot]
e4a766f2f4 chore(ui): refresh zh-TW control ui locale 2026-05-31 23:20:54 +00:00
github-actions[bot]
ad07ba141d chore(ui): refresh pt-BR control ui locale 2026-05-31 23:20:46 +00:00
github-actions[bot]
bd78737f94 chore(ui): refresh de control ui locale 2026-05-31 23:20:41 +00:00
github-actions[bot]
5f6e608c60 chore(ui): refresh zh-CN control ui locale 2026-05-31 23:20:37 +00:00
Vincent Koc
ddbd16a04a fix(ui): honor chromium executable override 2026-06-01 00:20:10 +01:00
Vincent Koc
03151a2ebe test(release): repair stale e2e mocks 2026-06-01 00:20:10 +01:00
Vincent Koc
1b69e7a005 fix(plugin-sdk): keep llm core alias on source graph 2026-06-01 00:20:10 +01:00
Vincent Koc
227530f906 test(imessage): align service-qualified target expectations 2026-06-01 00:20:10 +01:00
Vincent Koc
6df3fd5730 fix(gateway): list commands from gateway plugin registry 2026-06-01 00:20:10 +01:00
Vincent Koc
7c315252d6 test(whatsapp): wait on inbox delivery in monitor helper 2026-06-01 00:20:10 +01:00
Vincent Koc
0d7abcc94f test(telegram): exercise blocked spooled timeout lane 2026-06-01 00:20:09 +01:00
Vincent Koc
344773ba09 fix(openrouter): cap music stream request timeouts 2026-06-01 00:20:09 +01:00
Vincent Koc
ae4550f48b test(qa-lab): preserve cleanup phase labels 2026-06-01 00:20:09 +01:00
Vincent Koc
fdd02444b7 ci: add ARM Testbox lane 2026-06-01 00:20:09 +01:00
Peter Steinberger
3491834d49 Migrate iMessage monitor state to SQLite (#88797)
* refactor: move imessage monitor state to sqlite

* test: use OpenClaw temp root in iMessage state helper

* test: avoid pending promise lint in chat tests

* test: harden gateway ci flakes

* test: align session list merge expectation
2026-06-01 00:19:51 +01:00
Vincent Koc
12cf34a8ea refactor: share send inflight helpers 2026-06-01 01:18:38 +02:00
Peter Steinberger
d328a0d7a0 feat: calm chat composer controls 2026-06-01 00:18:04 +01:00
colmbrogan
421ad93203 fix(imessage): tolerate self-chat timestamp skew
Fixes iMessage self-chat reflection dedupe when reflected rows arrive with sub-second `created_at` skew, while keeping ambiguous normal-DM suppression exact-match only.

Maintainer follow-ups scoped skew tolerance to confirmed self-chat remembered rows and bounded cache cleanup so TTL-only expiry cannot leave the insertion-order queue growing indefinitely.

Verification:
- `node scripts/run-vitest.mjs extensions/imessage/src/monitor/self-chat-cache.test.ts extensions/imessage/src/monitor/self-chat-dedupe.test.ts extensions/imessage/src/monitor/inbound-processing.test.ts`
- `pnpm oxlint extensions/imessage/src/monitor/self-chat-cache.ts extensions/imessage/src/monitor/self-chat-cache.test.ts extensions/imessage/src/monitor/self-chat-dedupe.test.ts`
- `git diff --check origin/main...HEAD`
- autoreview clean on branch tip
- CI run 26727192244 green; Real behavior proof run 26727196218 green

Co-authored-by: Colm O Brogain <73212305+colmbrogan@users.noreply.github.com>
2026-06-01 00:14:47 +01:00
Vincent Koc
dc05f598bb fix(doctor): report runtime tool schema errors 2026-06-01 00:14:36 +01:00
Alix-007
3171278372 fix(gateway): hide phantom agent store rows from sessions.list
Fixes #57376.

Hide placeholder agent store keys from sessions.list while preserving real agent-scoped sessions.

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-01 00:14:09 +01:00
Feelw00
01193dea26 fix: make task persistence failures explicit
Preserve task and TaskFlow durability by persisting before in-memory registry mutation and surfacing explicit persistence failures instead of reporting fake success.

Adds non-throwing try-create runtime helpers while keeping existing throwing public create APIs compatible. Maintainer follow-up keeps task/TaskFlow sync repair bounded, prevents split task/delivery-state writes, and keeps CI green on the current base.

Thanks @Feelw00.
2026-06-01 00:12:28 +01:00
Coder
cb9847968a fix(subagents): roll token usage formatters over to m
Roll both subagent token usage formatters over to the million unit when rounded thousands reach the next unit.

The original fix covers `formatTokenShort`, which feeds the subagent list usage line. The maintainer follow-up applies the same unit-boundary rule to compact subagent announcement stats, preserving that formatter's one-decimal style while preventing `1000.0k` output.

Verification:
- focused runtime probe for list and compact announce stats at 999,999 tokens
- `oxfmt --check` on touched formatter/test files
- `git diff --check origin/main..HEAD`
- `node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test-pr88209.tsbuildinfo`
- autoreview local closeout clean
- exact-head CI passed for Real behavior proof, check-test-types, check-prod-types, check-guards, security-fast, and preflight

Known unrelated current-main reds at merge: `check-lint`, `checks-node-agentic-gateway-methods`, and `checks-node-agentic-control-plane-agent-chat`.

Co-authored-by: coder999999999 <coder999999999@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:07:45 +01:00
Vincent Koc
54987715f3 fix(ci): repair main lint and gateway session tests 2026-06-01 00:05:41 +01:00
Silvester
0c74f18a1c fix(microsoft-foundry): skip DeepSeek V4 thinking params on Foundry fallback
Skip the generic DeepSeek V4 OpenAI-compatible `thinking` payload wrapper for Microsoft Foundry fallback models. Foundry's OpenAI-compatible gateway rejects the non-standard top-level `thinking` argument, while the rest of the DeepSeek proxy path still keeps the wrapper.

Proof:
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/agents/embedded-agent-runner-extraparams.test.ts"
- node scripts/run-vitest.mjs src/agents/embedded-agent-runner-extraparams.test.ts passed, 130/130
- CI run 26681069909 passed for c950ac112e

Thanks @silvesterxm.
2026-06-01 00:03:32 +01:00
Vincent Koc
59122812c0 refactor: share agent id resolver 2026-06-01 01:03:07 +02:00
Alix-007
bc95af1b7c fix(memory-core): stop dream diary fallback leaks
Stop memory-core dream diary fallback paths from persisting raw memory staging snippets or promotions into DREAMS.md when narrative generation times out, returns empty output, or fails in request-scoped runtime. Successful generated narratives are unchanged.

Maintainer fixup: align current gateway session-list tests with the full loadSessionEntry mock shape and model-derived context token behavior on main.

Fixes #88391

Co-authored-by: Alix-007 <li.long15@xydigit.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:00:22 +01:00
yozakura-ava
144405e562 fix(agents): cap bootstrap snapshot cache
Cap the in-memory workspace bootstrap snapshot cache to 64 session keys so long-lived gateway processes do not retain one loaded bundle per distinct session key indefinitely. Older entries are evicted while active keys continue refreshing against the guarded workspace loader.

Verification:
- node scripts/run-vitest.mjs src/agents/bootstrap-cache.test.ts

PR: #88149
2026-05-31 23:56:47 +01:00
Vincent Koc
290b19275b refactor: share cron request helpers 2026-06-01 00:53:00 +02:00
Rain
72f74b33e1 fix(agents): guard transport payload sanitizer against non-string input
sanitizeTransportPayloadText() called text.replace() directly, so runtime-undefined content from malformed replay/error handling could crash embedded agent transport serialization with "Cannot read properties of undefined (reading 'replace')".

Return an empty string for non-string runtime payloads at the shared sanitizer boundary, preserving existing unpaired-surrogate cleanup for strings. Empty values still degrade through sanitizeNonEmptyTransportPayloadText() to "(no output)" where that non-empty fallback is required.

Proof:
- pnpm test src/agents/transport-stream-shared.test.ts
- pnpm exec oxfmt --check --threads=1 src/agents/transport-stream-shared.ts src/agents/transport-stream-shared.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "pnpm test src/agents/transport-stream-shared.test.ts"

Fixes #60113

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 23:45:39 +01:00
Vincent Koc
bb673f47b2 refactor: share agent run snapshot shape 2026-06-01 00:39:13 +02:00
Vincent Koc
16ef9c1435 fix(ci): use reliable Azure Crabbox capacity 2026-06-01 00:38:11 +02:00
Peter Steinberger
2b30951b80 feat: calm composer controls (#88772) 2026-05-31 23:37:27 +01:00
Peter Steinberger
56b8030cd9 fix(qa-lab): avoid returning timer from promise executor 2026-05-31 23:34:13 +01:00
DocNR
5706619068 fix(nostr): decode npub allowFrom entries to hex correctly
Fix Nostr allowFrom npub normalization by returning the decoded hex string from nostr-tools instead of iterating the hex string as bytes.

Proof:
- node scripts/run-vitest.mjs extensions/nostr/src/nostr-bus.test.ts
- PR CI green at head 7c3433435b

Co-authored-by: DocNR <danieljwyler@gmail.com>
2026-05-31 23:33:45 +01:00
Vincent Koc
edc0a22179 fix(agents): quarantine tools before schema normalization 2026-05-31 23:33:03 +01:00
Peter Steinberger
2682c02774 perf: hydrate chat history session metadata
Use chat.history metadata to hydrate TUI and web startup state without the extra sessions.list refresh, with guards for aliases, stale active rows, blank-session defaults, and lightweight TUI usage metadata.
2026-05-31 23:31:15 +01:00
Vincent Koc
59683978e1 refactor: share voice-call config extraction 2026-06-01 00:19:33 +02:00
Peter Steinberger
c8f8907f15 fix(feishu): guard webhook readiness fetch 2026-05-31 23:18:09 +01:00
Vincent Koc
8eb1838dfa refactor: share web login unavailable response 2026-06-01 00:13:34 +02:00
Jason O'Neal
01f6ad6056 fix: suppress raw provider errors in channel delivery
Fixes #69737.

Suppresses raw and raw-derived provider error text at the user-facing assistant lifecycle and reply-payload boundaries, including structured provider payloads, escaped JSON payloads, and aborted turns carrying provider failures. Keeps safe schema rejection and rate-limit guidance while preserving internal diagnostics.

Proof:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts src/agents/embedded-agent-runner/run/payloads.errors.test.ts src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts src/agents/embedded-agent-helpers/errors.test.ts
- oxfmt --check on touched files
- git diff --check origin/main...HEAD
- autoreview --mode branch --base origin/main clean
- GitHub exact-head checks green on b46e197f62

Co-authored-by: Jason O'Neal <jason.allen.oneal@gmail.com>
2026-05-31 23:10:46 +01:00
Peter Steinberger
b7f657b3b0 chore(lint): fix app render promise executor 2026-05-31 23:10:00 +01:00
Peter Steinberger
22cb7fb6b7 chore(lint): enable no-promise-executor-return 2026-05-31 23:06:13 +01:00
Vincent Koc
48afba96a3 refactor: share agents handler helpers 2026-06-01 00:02:22 +02:00
github-actions[bot]
470a1ae8d1 chore(ui): refresh nl control ui locale 2026-05-31 21:59:56 +00:00
github-actions[bot]
a2acfc5049 chore(ui): refresh fa control ui locale 2026-05-31 21:59:48 +00:00
github-actions[bot]
fe8c781d67 chore(ui): refresh vi control ui locale 2026-05-31 21:59:18 +00:00
github-actions[bot]
ac2484f23e chore(ui): refresh pl control ui locale 2026-05-31 21:59:13 +00:00
github-actions[bot]
cabfbdfe0d chore(ui): refresh id control ui locale 2026-05-31 21:59:11 +00:00
github-actions[bot]
5e2472567a chore(ui): refresh th control ui locale 2026-05-31 21:59:07 +00:00
github-actions[bot]
79c4ac73d7 chore(ui): refresh tr control ui locale 2026-05-31 21:58:33 +00:00
github-actions[bot]
2a1882ebcc chore(ui): refresh uk control ui locale 2026-05-31 21:58:31 +00:00
github-actions[bot]
3bb04b67e9 chore(ui): refresh it control ui locale 2026-05-31 21:58:25 +00:00
github-actions[bot]
cd0a7b10e2 chore(ui): refresh ar control ui locale 2026-05-31 21:58:21 +00:00
github-actions[bot]
bc45c36dbc chore(ui): refresh fr control ui locale 2026-05-31 21:57:51 +00:00
github-actions[bot]
7184522fae chore(ui): refresh ko control ui locale 2026-05-31 21:57:45 +00:00
github-actions[bot]
aa74d93aff chore(ui): refresh es control ui locale 2026-05-31 21:57:40 +00:00
github-actions[bot]
be0d3489a6 chore(ui): refresh ja-JP control ui locale 2026-05-31 21:57:38 +00:00
github-actions[bot]
f06b4b9aab chore(ui): refresh pt-BR control ui locale 2026-05-31 21:57:10 +00:00
github-actions[bot]
0700f13d62 chore(ui): refresh zh-TW control ui locale 2026-05-31 21:57:05 +00:00
github-actions[bot]
3c6c247e0a chore(ui): refresh de control ui locale 2026-05-31 21:57:01 +00:00
github-actions[bot]
2e42b1372e chore(ui): refresh zh-CN control ui locale 2026-05-31 21:56:58 +00:00
Shakker
f78bb34cb4 fix: translate Skill Workshop locale strings 2026-05-31 22:55:03 +01:00
Shakker
85c7490f72 fix: refresh Skill Workshop i18n outputs 2026-05-31 22:55:03 +01:00
Shakker
63d93db867 fix: refresh Skill Workshop protocol models 2026-05-31 22:55:03 +01:00
Shakker
2976db4b2c fix: address Skill Workshop UI check failures 2026-05-31 22:55:03 +01:00
Shakker
025bb01268 fix: constrain Skill Workshop navigation 2026-05-31 22:55:03 +01:00
Shakker
7a292bb16e fix: improve Skill Workshop empty states 2026-05-31 22:55:03 +01:00
Shakker
a9e3eade5d fix: tighten Skill Workshop today actions 2026-05-31 22:55:03 +01:00
Shakker
3733cd8d63 fix: clarify Skill Workshop proposal preview 2026-05-31 22:55:03 +01:00
Shakker
190f935b53 fix: address Skill Workshop review findings 2026-05-31 22:55:03 +01:00
Shakker
c21e16c73d fix: add Skill Workshop empty state 2026-05-31 22:55:03 +01:00
Shakker
d52f1ea5ec fix: tighten Skill Workshop today actions 2026-05-31 22:55:03 +01:00
Shakker
13967e17e6 fix: distinguish created Skill Workshop proposals 2026-05-31 22:55:03 +01:00
Shakker
7ad2aa44dd fix: show assistant name in Skill Workshop 2026-05-31 22:55:03 +01:00
Shakker
874b3f921e fix: polish Skill Workshop revision handoff 2026-05-31 22:55:03 +01:00
Shakker
c11d5d6d65 feat: stage Skill Workshop revision handoff 2026-05-31 22:55:03 +01:00
Shakker
11631bf044 feat: animate Skill Workshop chat landing 2026-05-31 22:55:03 +01:00
Shakker
561e993282 fix: stabilize Skill Workshop revise handoff 2026-05-31 22:55:03 +01:00
Shakker
23bf48e69e feat: add reusable Control UI tooltip 2026-05-31 22:55:03 +01:00
Shakker
7d65ea3513 feat: style Skill Workshop revision controls 2026-05-31 22:55:03 +01:00
Shakker
bfac12a184 feat: route Skill Workshop revisions through reusable sessions 2026-05-31 22:55:03 +01:00
Shakker
cdcc151145 feat: attach agent session origin to workshop tool 2026-05-31 22:55:03 +01:00
Shakker
7681b95199 feat: persist Skill Workshop proposal origin 2026-05-31 22:55:03 +01:00
Shakker
caa08a6dc0 feat: show real Skill Workshop proposals 2026-05-31 22:55:03 +01:00
Shakker
4339d7c1d8 feat: add Skill Workshop revision dialog 2026-05-31 22:55:03 +01:00
Shakker
aa187c6496 feat: add Skill Workshop today view 2026-05-31 22:55:03 +01:00
Shakker
34010894c1 feat: preview Skill Workshop actions 2026-05-31 22:55:03 +01:00
Shakker
c74bb4475a feat: resize Skill Workshop proposal list 2026-05-31 22:55:03 +01:00
Shakker
299a023bd1 fix: track reviewed workshop proposals 2026-05-31 22:55:03 +01:00
Shakker
0c852036c7 fix: refine Skill Workshop action bar 2026-05-31 22:55:03 +01:00
Shakker
9cc759dd37 fix: hide Skill Workshop actions after pending 2026-05-31 22:55:03 +01:00
Shakker
d1378650bb fix: keep file preview row focus clean 2026-05-31 22:55:03 +01:00
Shakker
40f99e474a fix: keep file preview keyboard focus modal 2026-05-31 22:55:03 +01:00
Shakker
dc71b5867e fix: align live tool stream labels 2026-05-31 22:55:03 +01:00
Shakker
fd2c65f59b refactor: extract file preview modal component 2026-05-31 22:55:03 +01:00
Shakker
575f74293e feat: search Skill Workshop preview files 2026-05-31 22:55:03 +01:00
Shakker
b27ae3f6e7 fix: remove Skill Workshop modal search focus chrome 2026-05-31 22:55:03 +01:00
Shakker
b388d3dc71 style: add Skill Workshop file preview modal 2026-05-31 22:55:03 +01:00
Shakker
01b7ef9e88 feat: add Skill Workshop file preview modal 2026-05-31 22:55:03 +01:00
Shakker
4b89def277 fix: align Skill Workshop pane surface 2026-05-31 22:55:03 +01:00
Shakker
fabd9469cd fix: tighten Skill Workshop page spacing 2026-05-31 22:55:03 +01:00
Shakker
d3025b4007 fix: resolve Control UI public assets from base path 2026-05-31 22:55:03 +01:00
Shakker
c06096eabc fix: keep Control UI logo root-relative 2026-05-31 22:55:03 +01:00
Shakker
9577e0be5a feat: style Skill Workshop UI 2026-05-31 22:55:03 +01:00
Shakker
b12724b79b feat: add Skill Workshop demo view 2026-05-31 22:55:03 +01:00
Shakker
0de60cec12 feat: add Skill Workshop navigation tab 2026-05-31 22:55:03 +01:00
Vincent Koc
c6232347dc refactor: share exec approvals node invoke 2026-05-31 23:50:30 +02:00
xin zhuang
b73e135f97 fix: resolve google provider default API to google-generative-ai (#88480) (#88512)
When a configured Google provider/model row had no explicit
but had a baseUrl set, the fallback defaulted to openai-completions,
causing Gemini requests to route through the OpenAI Responses
transport instead of the native @google/genai transport.

Made resolveConfiguredProviderDefaultApi provider-aware: for the
google provider, the default API is now google-generative-ai.

Root cause: the generic fallback assumed any provider with a baseUrl
should use openai-completions, which is incorrect for Google's native
Gemini API.

Co-authored-by: xin <1052326311+xin@users.noreply.github.com>
2026-05-31 22:48:48 +01:00
github-actions[bot]
9b6c981260 chore(ui): refresh fa control ui locale 2026-05-31 21:46:04 +00:00
github-actions[bot]
02ac0ec48b chore(ui): refresh nl control ui locale 2026-05-31 21:46:00 +00:00
github-actions[bot]
d8329dedf6 chore(ui): refresh pl control ui locale 2026-05-31 21:45:39 +00:00
github-actions[bot]
b86e8bf359 chore(ui): refresh id control ui locale 2026-05-31 21:45:30 +00:00
github-actions[bot]
3bb9224836 chore(ui): refresh vi control ui locale 2026-05-31 21:45:24 +00:00
github-actions[bot]
fdc10a64e9 chore(ui): refresh th control ui locale 2026-05-31 21:45:20 +00:00
github-actions[bot]
87174c80b6 chore(ui): refresh uk control ui locale 2026-05-31 21:44:45 +00:00
github-actions[bot]
97c040f946 chore(ui): refresh it control ui locale 2026-05-31 21:44:40 +00:00
github-actions[bot]
f833e96a31 chore(ui): refresh tr control ui locale 2026-05-31 21:44:38 +00:00
github-actions[bot]
9a32c0f85d chore(ui): refresh ar control ui locale 2026-05-31 21:44:35 +00:00
github-actions[bot]
d306f5bf2e chore(ui): refresh fr control ui locale 2026-05-31 21:44:02 +00:00
github-actions[bot]
65d5f7436c chore(ui): refresh ko control ui locale 2026-05-31 21:43:55 +00:00
github-actions[bot]
b78ce079a3 chore(ui): refresh ja-JP control ui locale 2026-05-31 21:43:51 +00:00
github-actions[bot]
6c6cf41b14 chore(ui): refresh es control ui locale 2026-05-31 21:43:42 +00:00
github-actions[bot]
0d79cbab4e chore(ui): refresh pt-BR control ui locale 2026-05-31 21:43:18 +00:00
github-actions[bot]
b04c3e96d6 chore(ui): refresh zh-CN control ui locale 2026-05-31 21:43:11 +00:00
github-actions[bot]
3854a61bea chore(ui): refresh de control ui locale 2026-05-31 21:43:07 +00:00
github-actions[bot]
0d07e30725 chore(ui): refresh zh-TW control ui locale 2026-05-31 21:43:01 +00:00
Ted Li
bfc151e9d3 fix(feishu): preserve long streaming replies
Preserve long Feishu streaming replies by falling oversized finals back to chunked message/static-card delivery instead of closing through an over-limit streaming CardKit payload.

Keeps late-final suppression after a streaming card closes, and uses markdown-aware chunking for static card fallback replies.

Fixes #88631.

Co-authored-by: Ted Li <tl2493@columbia.edu>
2026-05-31 22:41:38 +01:00
Peter Steinberger
b653d94918 chore(lint): enable no-useless-assignment 2026-05-31 22:40:48 +01:00
Andy Ye
49e5091f18 fix(update): recognize manual-update launchd jobs (#88764)
* Recognize manual update launchd jobs

* fix(update): avoid stale launchd false positives

* fix(update): filter stale doctor launchd checks

* fix(update): narrow manual launchd updater labels

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 22:39:37 +01:00
zhang-guiping
cbdb59b255 fix(agents): keep light isolated subagents lightweight
Keep native subagent spawns with `lightContext=true` and resolved isolated context out of context-engine pre-spawn preparation so they remain lightweight.

The normal isolated and forked context-engine lifecycle stays intact, and docs now call out the lightweight isolated exception.

Fixes #81214
2026-05-31 22:37:59 +01:00
Vincent Koc
2ac2a8d210 refactor: share channel operation validation 2026-05-31 23:35:19 +02:00
Shubhankar Tripathy
d042452d20 fix(logging): refresh file log hostname per write
Fix JSONL file-log hostnames getting pinned to `unknown` when the first hostname read returns an empty value. The logger now retries empty hostname reads and caches the first non-empty value, keeping the top-level `hostname` and `_meta.hostname` fields aligned.

Fixes #87258.
Thanks @lonexreb for the fix.

Verification:
- `node scripts/run-vitest.mjs src/logging/logger-redaction-behavior.test.ts src/logger.test.ts`
- `node_modules/.bin/oxfmt --check --threads=1 src/logging/logger.ts src/logging/logger-redaction-behavior.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- `gh pr checks 88131 --watch=false`

Co-authored-by: lonexreb <reach2shubhankar@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 22:35:04 +01:00
Peter Steinberger
50f27ee91d docs: document code-mode MCP API files 2026-05-31 22:33:06 +01:00
charles-openclaw
84266cd30e fix(models): strip remaining provider self prefixes (#88781)
* fix(models): strip remaining provider self prefixes

* fix(models): keep catalog refs prefix-preserving

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 22:29:47 +01:00
David
61e9961abb fix(agents): expose session status route context
Expose session status route context so agents can distinguish session origin, active live route, and persisted delivery route.

Add maintainer fixup to keep active route metadata on the real live run key when policy and run keys differ.

Thanks @nxmxbbd.

Closes #84544
2026-05-31 22:25:47 +01:00
Ashd.LW.
7c04ce3a79 fix(daemon): preserve container service env across regen
Preserve the current container-related service opt-in environment when regenerating daemon service files, while continuing to drop stale or arbitrary `OPENCLAW_*` variables.

Verification:
- `git diff --check`
- `node scripts/run-vitest.mjs src/commands/daemon-install-helpers.test.ts -t "operator opt-in allowlist"`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --prompt "Review PR #82828 fixup for daemon service env preservation. Focus on whether the allowlist should include only current container opt-in env keys and whether tests cover stale/arbitrary OPENCLAW_* filtering."`
- GitHub CI on `2e4b7f7fccbc46541c9c0ac271b1c97f1a6aa071`

Co-authored-by: wAngByg <281221101+wAngByg@users.noreply.github.com>
2026-05-31 22:22:24 +01:00
Vincent Koc
2ff9e27d4e refactor: share skill proposal workspace handling 2026-05-31 23:21:27 +02:00
Peter Steinberger
5ee3e5d8c0 docs: require real Crabbox visual proof 2026-05-31 22:18:31 +01:00
waterblue
03dec8bb3a fix(openai): avoid replay ids when Responses store is disabled
Avoid replaying prior OpenAI Responses reasoning/message/function-call item ids when the outgoing request disables store, while preserving encrypted reasoning and normalized summary arrays for stateless replay. Keep explicit store-enabled OpenAI wrapper paths opted into item-id replay, and cover shared/simple Responses, ChatGPT/Codex Responses, and GitHub Copilot sanitizer behavior.

Regression tests cover store-disabled id omission, encrypted reasoning preservation, idless Copilot reasoning replay, and direct builder payloads. Local proof included focused Vitest, broad lint, broad test-types, bundled-extension lint, plugin boundary checks, autoreview clean, and live OpenAI Responses gpt-5.5 proof.

Co-authored-by: hang <zhanghang02@gmail.com>
2026-05-31 22:17:32 +01:00
Arnab Saha
5bc80dbe27 fix(diagnostics): carry session UUID on interactive dispatch events
Carry the canonical session UUID from the session store into interactive dispatch diagnostic lifecycle events, matching the cron path so downstream diagnostic consumers can join events back to the JSONL transcript id.

Guard native command redirects by only attaching the UUID when the lifecycle session key matches the session-store lookup key, avoiding a target UUID under a source conversation key.

Verification:
- `pnpm test src/auto-reply/reply/dispatch-from-config.test.ts -t "carries the session store UUID|does not stamp a command target"`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --prompt ...`
- synthetic merge-tree against current `origin/main`

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:15:15 +01:00
Peter Steinberger
8383e2e4d9 fix(doctor): keep post-upgrade JSON stable 2026-05-31 22:12:38 +01:00
Arnab Saha
7f93755206 fix(doctor): post-upgrade entry probe now delegates to install resolver
Address Codex review (P2 x3): replace the duplicate fs.access-based entry
checks in runPostUpgradeProbes with a call to
validatePackageExtensionEntriesForInstall so the doctor probe enforces the
same contract as plugin install/discovery:

- runtimeExtensions shape and length-mismatch validation
- plugin-root boundary enforcement (rejects absolute paths and ../ escapes)
- inferred dist/*.js peer for TypeScript entries; TS source-only entries
  without compiled output are now flagged

Adds 4 regression tests covering the boundary-escape, dist-peer accept,
TS-source-only reject, and runtimeExtensions length-mismatch cases.

Refs: https://github.com/openclaw/openclaw/pull/79260#issuecomment-4403594002
2026-05-31 22:12:38 +01:00
Arnab Saha
7dd1bd894b fix(doctor): drop unused listBuiltRuntimeEntryCandidates import and brace bare if continue 2026-05-31 22:12:38 +01:00
Arnab Saha
6ed6120977 docs(doctor): document --post-upgrade and --json flags 2026-05-31 22:12:38 +01:00
Arnab Saha
0f396368a9 fix(doctor): honor runtimeExtensions before flagging entry_unresolved 2026-05-31 22:12:38 +01:00
Arnab Saha
72679b16eb fix(doctor): resolve plugin index via state-dir helper 2026-05-31 22:12:38 +01:00
Arnab Saha
4a09fd43e2 docs(changelog): note doctor --post-upgrade --json 2026-05-31 22:12:38 +01:00
Arnab Saha
026ab6b882 feat(doctor): expose --post-upgrade and --json CLI flags 2026-05-31 22:12:38 +01:00
Arnab Saha
730492867f feat(doctor): branch into post-upgrade probe runner when --post-upgrade 2026-05-31 22:12:38 +01:00
Arnab Saha
ceda284845 feat(doctor): add plugin.manifest_drift post-upgrade probe 2026-05-31 22:12:38 +01:00
Arnab Saha
8da6b67607 fix(doctor): clean up post-upgrade probe test temp dirs and skip plugins with unreadable package.json 2026-05-31 22:12:38 +01:00
Arnab Saha
e0d3c78042 feat(doctor): add plugin.entry_unresolved post-upgrade probe 2026-05-31 22:12:38 +01:00
Arnab Saha
af7749123b feat(doctor): add post-upgrade finding types 2026-05-31 22:12:38 +01:00
alkor2000
9d97e683d4 feat(doctor): add disk space health check
Add a Doctor health contribution that checks free space on the partition containing the active OpenClaw state directory. Doctor now warns below 500 MB and reports critical below 100 MB so disk pressure is visible before config writes, session transcripts, or log rotation start failing.

The contribution reuses the shared `src/infra/disk-space.ts` probe, runs before state integrity, and is registered in the Doctor health conversion plan with focused coverage for thresholds, formatting, and note behavior.

PR: #59196
Proof: `pnpm test src/commands/doctor-disk-space.test.ts src/flows/doctor-health-conversion-plan.test.ts`; `git diff --check origin/main...HEAD`; `git merge-tree --write-tree origin/main refs/remotes/pr/59196`; GitHub CI run `26720861380`; Real behavior proof run `26720996848`.

Co-authored-by: alkor2000 <200923177@qq.com>
2026-05-31 22:09:36 +01:00
Vincent Koc
e2c745fc58 refactor: share agent wait terminal snapshot 2026-05-31 23:08:28 +02:00
Andy Ye
5df0ed3b9f fix(agents): publish owned announcement session writes
Forward prompt-submission owned session write publication into the embedded session lock controller so same-process announcement/completion writes can advance the requester fence while external edits still trigger takeover protection.

Adds regression coverage for a second controller publishing an owned announcement write and for preserving rejection of a later unowned edit.

Closes #88703.

Thanks @TurboTheTurtle.
2026-05-31 22:00:37 +01:00
Ted Li
e5acae4453 fix(ui): show Workboard comments in edit modal
Show existing Workboard card comments in the edit modal and allow operators to append a new comment through the existing `workboard.cards.comment` gateway method.

Refs #88592.

Verification:
- node scripts/run-vitest.mjs ui/src/ui/views/workboard.test.ts
- pnpm tsgo:test:ui
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Co-authored-by: Ted Li <tl2493@columbia.edu>
2026-05-31 21:54:26 +01:00
Peter Steinberger
8076eead77 test(channels): narrow pending ingress duplicate 2026-05-31 21:53:42 +01:00
Peter Steinberger
f6365d07c4 fix(agents): wait for cron media completions
Keep cron media generation detached while making cron runs wait for image/music/video completion before final closeout. Records async task IDs, falls back to the task registry for active run-scoped media work, handles timeout races, and scopes no-target generated-media delivery. Fixes #88001.
2026-05-31 21:51:38 +01:00
Sebastien Tardif
9a3e7d4f51 fix(hooks): pass media metadata to internal message_received hook
Forward canonical inbound media metadata to internal message:received hook consumers, matching the plugin received hook mapper and inbound-claim metadata path.

This fixes internal hook handlers losing mediaPath, mediaUrl, mediaType, mediaPaths, mediaUrls, and mediaTypes for received messages with attachments.

Verification:
- node scripts/run-vitest.mjs src/hooks/message-hook-mappers.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Refs: https://github.com/openclaw/openclaw/pull/88740
Thanks @SebTardif.
Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-31 21:49:36 +01:00
Peter Steinberger
ce1165afda fix: repair providerless Codex session overrides
Co-authored-by: Earl Vanze <earlvanze@gmail.com>
2026-05-31 21:45:39 +01:00
brokemac79
90712f6d5e [codex] Surface disabled Codex plugin routes in doctor lint (#88761)
Merged via squash.

Prepared head SHA: 41bcde2d7d
Co-authored-by: brokemac79 <255583030+brokemac79@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-05-31 13:34:53 -07:00
ArthurNie
7c15c2765e fix(feishu): fallback when accepted turns send no visible reply (#87896)
* fix(feishu): fallback when accepted turns send no visible reply

* fix(feishu): cover no-visible-reply fallback gaps

* fix(feishu): mark media replies visible

* fix(feishu): honor suppressed delivery fallback

* test(auto-reply): trim fallback test churn

* fix(feishu): gate empty fallback eligibility

* test(auto-reply): expect fallback metadata after denied dispatch

* fix(feishu): fallback after failed visible final sends

* test(feishu): keep reply dispatcher mock shape aligned

* fix(auto-reply): respect silent policy for no-visible fallback

* fix(feishu): wait for streaming close before fallback

* fix(feishu): clear silent skip before later finals

* fix(feishu): preserve visible state across keepalives

* test(feishu): align lifecycle dispatcher mocks

* fix(feishu): require accepted streaming content for fallback

---------

Co-authored-by: ArthurNie <264332276+ArthurNie@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-31 21:33:13 +01:00
Peter Steinberger
e681569536 feat: add code-mode MCP API files
* feat: add code-mode MCP API files

* fix: satisfy code-mode MCP lint
2026-05-31 21:29:06 +01:00
Peter Steinberger
b0679d1f13 refactor(channels): store inbound queues in SQLite 2026-05-31 21:15:29 +01:00
Peter Steinberger
80b7f56603 ci: pin Azure crabbox lane to eastus2 2026-05-31 21:11:43 +01:00
Peter Steinberger
995a9bd702 chore(ui): refresh notification i18n metadata 2026-05-31 21:09:37 +01:00
Peter Steinberger
92b9cd21ec test: avoid positional CI check assertion 2026-05-31 16:00:04 -04:00
Peter Steinberger
d62bfab946 ci: split startup and shrinkwrap checks 2026-05-31 15:55:43 -04:00
Peter Steinberger
7aa309319f test(auto-reply): align debounce timer tests 2026-05-31 20:48:02 +01:00
Peter Steinberger
2df95c0b10 chore(lint): enable no-misused-promises 2026-05-31 20:42:13 +01:00
Peter Steinberger
6f58a71582 test(voice-call): install state runtime for events 2026-05-31 20:41:14 +01:00
stain lu
55fc3c10b0 fix(openai/tts): handle speed directives (#74089)
Adds OpenAI speech speed directive parsing with official OpenAI range validation and custom endpoint passthrough. Closes #12163.
2026-05-31 20:35:46 +01:00
Peter Steinberger
b4a6244ef4 ci: split agents core test shard 2026-05-31 15:35:36 -04:00
Peter Steinberger
6b2cb4db67 fix: polish notifications settings UI 2026-05-31 20:35:10 +01:00
Vincent Koc
0715081990 test(agents): narrow bundle mcp e2e setup 2026-05-31 21:31:52 +02:00
WT-WSL
462b52f62c fix(ci): guard workflow template injection
Guard the remaining Windows Testbox workflow ref logging against GitHub Actions template injection by moving `target_ref` through step env before PowerShell reads it.

Extend the local workflow check wrapper to run pinned `zizmor` across every workflow file, and keep Workflow Sanity's CI audit explicit with trusted-base pre-commit and zizmor configs for pull-request runs.

Thanks @WT-WSL for the original report and patch.

Co-authored-by: dev111-actor <captaintobb@outlook.com>
2026-05-31 20:28:40 +01:00
Peter Steinberger
118b9cacf6 refactor: split ACP manager session flows
Split ACP manager session-flow ownership into focused helpers for initialization, status reads, cancellation, and startup identity reconciliation.

Verification:
- `node scripts/run-oxlint.mjs src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.initialize-session.ts src/acp/control-plane/manager.status.ts src/acp/control-plane/manager.cancel-session.ts src/acp/control-plane/manager.startup-identity-reconcile.ts src/acp/control-plane/manager.close-session.ts src/acp/control-plane/manager.turn-runner.ts src/acp/control-plane/manager.runtime-options-commands.ts src/acp/control-plane/manager.types.ts src/acp/control-plane/manager.test.ts src/acp/control-plane/manager.initialize-session.test.ts src/acp/control-plane/manager.cancel-session.test.ts src/acp/control-plane/manager.startup-identity-reconcile.test.ts src/acp/control-plane/manager.runtime-config.test.ts`
- `pnpm tsgo:prod`
- `pnpm test src/acp/control-plane/manager.test.ts src/acp/control-plane/manager.initialize-session.test.ts src/acp/control-plane/manager.cancel-session.test.ts src/acp/control-plane/manager.startup-identity-reconcile.test.ts src/acp/control-plane/manager.runtime-config.test.ts src/acp/control-plane/manager.runtime-handles.test.ts`
- `pnpm format:check src/acp/control-plane/manager.core.ts src/acp/control-plane/manager.initialize-session.ts src/acp/control-plane/manager.status.ts src/acp/control-plane/manager.cancel-session.ts src/acp/control-plane/manager.startup-identity-reconcile.ts src/acp/control-plane/manager.close-session.ts src/acp/control-plane/manager.turn-runner.ts src/acp/control-plane/manager.runtime-options-commands.ts src/acp/control-plane/manager.types.ts src/acp/control-plane/manager.test.ts src/acp/control-plane/manager.initialize-session.test.ts src/acp/control-plane/manager.cancel-session.test.ts src/acp/control-plane/manager.startup-identity-reconcile.test.ts src/acp/control-plane/manager.runtime-config.test.ts`
- `git diff --check`
- `pnpm check:test-types`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- GitHub PR checks for #88752 passed

Real behavior proof:
Behavior addressed: ACP manager session-flow ownership is split out of `AcpSessionManager` without changing initialization, status, cancel, startup identity reconciliation, close, turn, or runtime-option behavior.
Real environment tested: Local OpenClaw checkout, Node/pnpm repo toolchain, GitHub Actions PR CI.
Exact steps or command run after this patch: Focused ACP manager/runtime config/runtime handle tests plus prod/test type checks, lint, format check, diff check, autoreview, and PR CI.
Evidence after fix: All listed local commands passed, autoreview reported no accepted/actionable findings, and GitHub PR checks passed.
Observed result after fix: `manager.core.ts` is down to 612 LOC, with init/status/cancel/startup identity flows in focused modules and matching focused tests.
What was not tested: Live ACP backend session initialization/cancel/status against a real external ACP provider.
2026-05-31 20:26:04 +01:00
1107 changed files with 33168 additions and 8681 deletions

View File

@@ -4,11 +4,11 @@ profile: openclaw-check
provider: azure
class: standard
capacity:
market: spot
market: on-demand
strategy: most-available
# Fail closed instead of silently falling back to on-demand while the
# Azure-backed billing account is the default runner path.
fallback: spot-only
# The Azure-backed billing account carries the OpenClaw runner credits; use
# explicit on-demand capacity instead of low-priority spot, whose regional
# quota is too small for broad maintainer proof or parallel Crabbox lanes.
hints: true
actions:
workflow: .github/workflows/crabbox-hydrate.yml
@@ -48,6 +48,10 @@ aws:
# leaking AWS region names into the Azure default capacity fallback list.
region: eu-west-1
rootGB: 400
azure:
# The OpenClaw Azure subscription is reliable in eastus2; eastus rejects the
# same SKUs and can stall provisioning.
location: eastus2
sync:
delete: true
checksum: false
@@ -67,13 +71,16 @@ env:
- OPENCLAW_*
ssh:
user: crabbox
port: "2222"
# Azure coordinator leases expose SSH on 22. The run wrapper can fall back
# from 2222, but `crabbox job run` hydrates via the configured port directly.
port: "22"
jobs:
prewarm:
provider: azure
target: linux
class: standard
market: spot
type: Standard_D4ads_v6
market: on-demand
idleTimeout: 90m
hydrate:
actions: true
@@ -90,7 +97,8 @@ jobs:
provider: azure
target: linux
class: standard
market: spot
type: Standard_D4ads_v6
market: on-demand
idleTimeout: 90m
hydrate:
actions: true
@@ -99,7 +107,18 @@ jobs:
workflow: .github/workflows/crabbox-hydrate.yml
job: hydrate
ref: main
command: env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 CI=1 corepack pnpm check:changed
shell: true
command: |
set -euo pipefail
if ! git status --short >/dev/null 2>&1; then
rm -rf .git
git init -q
git add -A
if ! git diff --cached --quiet; then
git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-check-tree
fi
fi
env CI=1 corepack pnpm check --timed
stop: always
testbox-changed:
provider: blacksmith-testbox

View File

@@ -139,3 +139,139 @@ jobs:
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
check-arm:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
permissions:
contents: read
name: "check-arm"
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
timeout-minutes: 120
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Verify ARM runner
shell: bash
run: |
set -euo pipefail
runner_arch="$(uname -m)"
echo "check-arm runner architecture: ${runner_arch}"
case "$runner_arch" in
aarch64 | arm64)
;;
*)
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
exit 1
;;
esac
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
if [[ -z "$CHECKOUT_TOKEN" ]]; then
echo "checkout token is missing" >&2
exit 1
fi
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
PNPM
sudo chmod 0755 /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -1202,6 +1202,9 @@ jobs:
- check_name: check-guards
task: guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-shrinkwrap
task: shrinkwrap
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-prod-types
task: prod-types
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1277,7 +1280,6 @@ jobs:
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
pnpm deps:shrinkwrap:check
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
@@ -1286,6 +1288,9 @@ jobs:
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
;;
shrinkwrap)
pnpm deps:shrinkwrap:check
;;
prod-types)
pnpm tsgo:prod
;;

View File

@@ -43,4 +43,4 @@ jobs:
published_upgrade_survivor_baselines: ${{ inputs.baselines }}
published_upgrade_survivor_scenarios: ${{ inputs.scenarios }}
telegram_mode: none
secrets: inherit
secrets: inherit # zizmor: ignore[secrets-inherit] Maintainer-dispatched package acceptance lane intentionally forwards its declared live-test secret matrix.

View File

@@ -61,12 +61,14 @@ jobs:
submodules: false
- name: Probe native Windows
env:
TARGET_REF: ${{ inputs.target_ref || github.ref }}
run: |
$ErrorActionPreference = "Stop"
Write-Host "runner=$env:RUNNER_NAME"
Write-Host "machine=$env:COMPUTERNAME"
Write-Host "workspace=$env:GITHUB_WORKSPACE"
Write-Host "target_ref=${{ inputs.target_ref || github.ref }}"
Write-Host "target_ref=$env:TARGET_REF"
Write-Host ("os=" + [System.Environment]::OSVersion.VersionString)
Write-Host ("arch=" + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
Write-Host ("powershell=" + $PSVersionTable.PSVersion.ToString())

View File

@@ -84,6 +84,65 @@ jobs:
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Prepare trusted workflow audit configs
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
fi
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \
> "$trusted_config" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead."
else
echo "::error title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}."
exit 1
fi
if git cat-file -e "${BASE_SHA}:.github/zizmor.yml" 2>/dev/null; then
git show "${BASE_SHA}:.github/zizmor.yml" > "$trusted_zizmor_config"
elif git show "refs/remotes/origin/${BASE_REF}:.github/zizmor.yml" \
> "$trusted_zizmor_config" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} does not expose .github/zizmor.yml; using origin/${BASE_REF} instead."
else
echo "::error title=trusted zizmor config unavailable::Could not read .github/zizmor.yml from ${BASE_SHA} or origin/${BASE_REF}."
exit 1
fi
python3 - "$trusted_config" "$trusted_zizmor_config" <<'PY'
from pathlib import Path
import sys
config_path = Path(sys.argv[1])
zizmor_config_path = sys.argv[2]
text = config_path.read_text()
if ".github/zizmor.yml" not in text:
raise SystemExit("trusted pre-commit config does not reference .github/zizmor.yml")
config_path.write_text(text.replace(".github/zizmor.yml", zizmor_config_path))
PY
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
- name: Install pre-commit
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
- name: Install actionlint
shell: bash
run: |
@@ -103,6 +162,15 @@ jobs:
- name: Lint workflows
run: actionlint
- name: Audit all workflows with zizmor
shell: bash
run: |
set -euo pipefail
mapfile -t workflow_files < <(
find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort
)
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py

View File

@@ -22,6 +22,7 @@
"eslint/no-object-constructor": "error",
"eslint/no-param-reassign": "error",
"eslint/no-proto": "error",
"eslint/no-promise-executor-return": "error",
"eslint/no-regex-spaces": "error",
"eslint/no-return-assign": "error",
"eslint/no-sequences": "error",
@@ -35,6 +36,7 @@
"eslint/no-useless-constructor": "error",
"eslint/no-useless-rename": "error",
"eslint/no-useless-return": "error",
"eslint/no-useless-assignment": "error",
"eslint/no-unused-vars": "error",
"eslint/no-warning-comments": "error",
"eslint/no-unmodified-loop-condition": "error",
@@ -78,6 +80,7 @@
"typescript/no-extraneous-class": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-misused-promises": "error",
"typescript/no-inferrable-types": "error",
"typescript/no-non-null-asserted-nullish-coalescing": "error",
"typescript/no-unnecessary-qualifier": "error",

View File

@@ -74,6 +74,7 @@ Skills own workflows; root owns hard policy and routing.
- Core runtime consumes only current canonical shapes/config/data. Legacy or retired shapes normalize only in doctor/migration code before runtime; no runtime shims, aliases, or fallback readers.
- State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in `openclaw doctor --fix` migration code only, never steady-state runtime.
- Storage default: SQLite only. Do not add JSON/JSONL/TXT/sidecar files for OpenClaw-owned runtime state, caches, queues, registries, indexes, cursors, checkpoints, or plugin scratch data.
- SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives.
- Use the shared state DB (`state/openclaw.sqlite`) for global runtime state and plugin KV data. Use the per-agent DB (`agents/<agentId>/agent/openclaw-agent.sqlite`) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores.
- Legacy state/cache files are migration debt. When touching code that reads/writes them, prefer moving the data into SQLite or calling out the refactor follow-up; do not add parallel file paths.
- File storage must be a named product artifact: import/export, user attachment, log, backup, or external tool contract. If it is app state or cache, it belongs in SQLite.
@@ -127,6 +128,7 @@ Skills own workflows; root owns hard policy and routing.
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
@@ -263,7 +265,7 @@ Skills own workflows; root owns hard policy and routing.
- Version bump surfaces live in `$release-openclaw-maintainer`.
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
- Before sharing WebVNC links, capture screenshot and verify target UI is not broken.
- Before sharing WebVNC links, use Crabbox screenshot first; verify real app/path works and target UI is not broken.
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
- `message_tool_only`: normal agent final visible reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Plugin-owned bound-thread reply = plugin return value; no message tool needed. Never auto-publish private final.

View File

@@ -57,7 +57,7 @@ Docs: https://docs.openclaw.ai
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.

View File

@@ -5528,6 +5528,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
public let createdat: String
public let updatedat: String
public let createdby: AnyCodable
public let origin: [String: AnyCodable]?
public let proposedversion: String
public let draftfile: String
public let drafthash: String
@@ -5552,6 +5553,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
createdat: String,
updatedat: String,
createdby: AnyCodable,
origin: [String: AnyCodable]?,
proposedversion: String,
draftfile: String,
drafthash: String,
@@ -5575,6 +5577,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
self.createdat = createdat
self.updatedat = updatedat
self.createdby = createdby
self.origin = origin
self.proposedversion = proposedversion
self.draftfile = draftfile
self.drafthash = drafthash
@@ -5600,6 +5603,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
case createdat = "createdAt"
case updatedat = "updatedAt"
case createdby = "createdBy"
case origin
case proposedversion = "proposedVersion"
case draftfile = "draftFile"
case drafthash = "draftHash"

View File

@@ -1,2 +1,2 @@
eadfc9b897a05664735f8e2abcb70cb3f33c19427c20802bf8b035520b7a2ea1 plugin-sdk-api-baseline.json
8e10e093068d73b9ac50d3f265bf7d892652b0392c677be4e332248499cf7ed0 plugin-sdk-api-baseline.jsonl
19bdf1196ec771a00777a16fd1e9c3662b8fd788a81034e705c41a74ee79c7ec plugin-sdk-api-baseline.json
43feff80c90adad0f821d1f1e184a9bff1e93d81e6d53a26a26fd9e2972be759 plugin-sdk-api-baseline.jsonl

View File

@@ -248,7 +248,7 @@ iMessage catchup is now available as an opt-in feature on the bundled plugin. On
There is no supported BlueBubbles runtime to switch back to. If iMessage verification fails, set `channels.imessage.enabled: false`, restart the Gateway, fix the `imsg` blocker, and retry the cutover.
The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate.
The reply cache lives in SQLite plugin state. `openclaw doctor --fix` imports and archives the old `imessage/reply-cache.jsonl` sidecar when present.
## Related

View File

@@ -533,7 +533,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
</Accordion>
<Accordion title="Message IDs">
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent in-memory reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent SQLite-backed reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
</Accordion>
@@ -714,7 +714,7 @@ Each replayed row is fed through the live dispatch path (`evaluateIMessageInboun
### Cursor and retry semantics
Catchup keeps a per-account cursor at `<openclawStateDir>/imessage/catchup/<account>__<hash>.json` (the OpenClaw state dir defaults to `~/.openclaw`, overridable with `OPENCLAW_STATE_DIR`):
Catchup keeps a per-account cursor in SQLite plugin state:
```json
{
@@ -729,6 +729,7 @@ Catchup keeps a per-account cursor at `<openclawStateDir>/imessage/catchup/<acco
- After the startup catchup query succeeds, later live-handled rows also advance the same cursor so a gateway restart does not replay messages that were already handled live. Live cursor writes do not jump past catchup failures that are still below `maxFailureRetries`.
- After `maxFailureRetries` consecutive throws against the same `guid`, catchup logs a `warn` and force-advances the cursor past the wedged message so subsequent startups can make progress.
- Already-given-up guids are skipped on sight (no dispatch attempt) on later runs and counted under `skippedGivenUp` in the run summary.
- `openclaw doctor --fix` imports legacy `<openclawStateDir>/imessage/catchup/*.json` cursor files into SQLite plugin state and archives the old files.
### Operator-visible signals

View File

@@ -15,7 +15,7 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
| Job | Purpose | When it runs |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-fast` | Private key detection, workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes |
@@ -80,6 +80,7 @@ apply to that PR.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
- **TUI PTY** is a focused workflow for TUI changes. It runs `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` on Linux Node 24 for `src/tui/**`, the watch harness, package script, lockfile, and workflow edits. The required lane uses a deterministic `TuiBackend` fixture; the slower `tui --local` smoke is opt-in with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and mocks only the external model endpoint.
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.

View File

@@ -45,6 +45,8 @@ openclaw doctor --deep
openclaw doctor --fix
openclaw doctor --fix --non-interactive
openclaw doctor --generate-gateway-token
openclaw doctor --post-upgrade
openclaw doctor --post-upgrade --json
```
For channel-specific permissions, use the channel probes instead of `doctor`:
@@ -68,7 +70,8 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
- `--allow-exec`: allow doctor to execute configured exec SecretRefs while verifying secrets
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
- `--json`: with `--lint`, emit JSON findings instead of human output
- `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present
- `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`)
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
@@ -188,6 +191,16 @@ id is not registered, no check runs for that id; use the command's `checksRun`
and `checksSkipped` fields to verify a focused gate is selecting the checks you
expect.
## Post-upgrade mode
`openclaw doctor --post-upgrade` runs plugin compatibility probes intended to be
chained after a build or upgrade. Findings are emitted to stdout; the command
exits with code 1 if any finding has `level: "error"`. Add `--json` to receive a
machine-readable envelope (`{ probesRun, findings }`) suitable for CI, the
community `fork-upgrade` skill, and other post-upgrade smoke tooling. If the
installed plugin index is missing or malformed, JSON mode still emits that
envelope with a `plugin.index_unavailable` error finding.
Notes:
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).

View File

@@ -91,7 +91,7 @@ For the bundled non-ACP Codex harness, OpenClaw applies the same lifecycle by pr
OpenClaw calls two optional subagent lifecycle hooks:
<ParamField path="prepareSubagentSpawn" type="method">
Prepare shared context state before a child run starts. The hook receives parent/child session keys, `contextMode` (`isolated` or `fork`), available transcript ids/files, and optional TTL. If it returns a rollback handle, OpenClaw calls it when spawn fails after preparation succeeds.
Prepare shared context state before a child run starts. The hook receives parent/child session keys, `contextMode` (`isolated` or `fork`), available transcript ids/files, and optional TTL. If it returns a rollback handle, OpenClaw calls it when spawn fails after preparation succeeds. Native subagent spawns that request `lightContext` and resolve to `contextMode="isolated"` intentionally skip this hook so the child starts from the lightweight bootstrap context without context-engine-managed pre-spawn state.
</ParamField>
<ParamField path="onSubagentEnded" type="method">
Clean up when a subagent session completes or is swept.

View File

@@ -120,6 +120,19 @@ sparse token/cache counters from the latest transcript usage entry, and
the caller's current session; visible client labels such as `openclaw-tui` are
not session keys.
When route metadata is available, `session_status` also includes a visible
`Route context` JSON block and matching structured `details` fields. These
fields disambiguate the session key from the route that is currently handling
the live run:
- `origin` is where the session was created, or the provider inferred from a
deliverable session-key prefix when older state lacks stored origin metadata.
- `active` is the current live-run route. It is only reported for the live or
current session being handled now.
- `deliveryContext` is the persisted delivery route stored on the session,
which OpenClaw can reuse for later delivery even when the active surface
differs.
`sessions_yield` intentionally ends the current turn so the next message can be
the follow-up event you are waiting for. Use it after spawning sub-agents when
you want completion results to arrive as the next message instead of building

View File

@@ -76,6 +76,37 @@ Server globs use the provider-safe MCP server prefix, not necessarily the raw `m
Without that sandbox-layer entry, the MCP server can still load successfully while its tools are filtered before the provider request. Use `openclaw doctor` to catch this shape for OpenClaw-managed servers in `mcp.servers`. MCP servers loaded from bundled plugin manifests or Claude `.mcp.json` use the same sandbox gate, but this diagnostic does not enumerate those sources yet; use the same allowlist entries if their tools disappear in sandboxed turns.
### `tools.codeMode`
`tools.codeMode` enables the generic OpenClaw code-mode surface. When enabled
for a run with tools, the model sees only `exec` and `wait`; normal OpenClaw
tools move behind the in-sandbox `tools.*` catalog bridge, and MCP tools are
available through the generated `MCP` namespace.
```json5
{
tools: {
codeMode: {
enabled: true,
},
},
}
```
The shorthand is also accepted:
```json5
{
tools: { codeMode: true },
}
```
MCP declarations are exposed through the read-only virtual API file surface in
code mode. Guest code can call `API.list("mcp")` and
`API.read("mcp/<server>.d.ts")` to inspect TypeScript-style signatures before
calling `MCP.<server>.<tool>()`. See [Code mode](/reference/code-mode) for the
runtime contract, limits, and debugging steps.
### `tools.allow` / `tools.deny`
Global tool allow/deny policy (deny wins). Case-insensitive, supports `*` wildcards. Applied even when Docker sandbox is off.

View File

@@ -100,6 +100,11 @@ The shorthand is also accepted:
Code mode remains off when `tools.codeMode` is omitted, `false`, or an object
without `enabled: true`.
When you use sandboxed agents with configured MCP servers, also make sure the
sandbox tool policy allows the bundled MCP plugin, for example with
`tools.sandbox.tools.alsoAllow: ["bundle-mcp"]`. See
[Configuration - tools and custom providers](/gateway/config-tools#mcp-and-plugin-tools-inside-sandbox-tool-policy).
Use explicit limits when you want tighter bounds:
```json5
@@ -441,12 +446,13 @@ const hits = await tools.web_search({ query: "OpenClaw code mode" });
MCP catalog entries are not callable through `tools.call(...)` or convenience
functions in code mode. They are exposed only through the generated `MCP`
namespace, which includes TypeScript-style API headers for discovery:
namespace. TypeScript-style declaration files are available through the
read-only `API` virtual file surface, so agents can inspect MCP signatures
without adding MCP schemas to the prompt:
```typescript
const servers = await MCP.$api();
const githubApi = await MCP.github.$api();
const createIssueApi = await MCP.github.$api("createIssue", { schema: true });
const files = await API.list("mcp");
const githubApi = await API.read("mcp/github.d.ts");
const issue = await MCP.github.createIssue({
owner: "openclaw",
@@ -462,7 +468,8 @@ const prompt = await MCP.docs.prompts.get({
});
```
`MCP.<server>.$api()` returns a compact header inferred from MCP tool metadata:
`API.read("mcp/<server>.d.ts")` returns compact declarations inferred from MCP
tool metadata:
```typescript
type McpToolResult = {
@@ -491,6 +498,20 @@ declare namespace MCP.github {
}
```
The declaration files are virtual, not files written under the workspace or
state directory. For each code-mode `exec` call, OpenClaw builds the run-scoped
tool catalog, keeps the visible MCP entries, renders `mcp/index.d.ts` plus one
`mcp/<server>.d.ts` declaration per visible server, and injects that small
read-only table into the QuickJS worker. Guest code sees only the `API` object:
`API.list(prefix?)` returns file metadata and `API.read(path)` returns the
selected declaration content. Unknown paths and `.` / `..` segments are rejected.
This keeps large MCP schemas out of the model prompt. The agent learns that the
virtual API exists from the `exec` tool description, reads only the needed
declaration file, and then calls `MCP.<server>.<tool>()` with one object argument.
`MCP.<server>.$api()` remains available as an inline fallback when the agent
needs a single-tool schema response inside the program.
The guest runtime must not expose host objects directly. Inputs and outputs cross
the bridge as JSON-compatible values with explicit size caps.
@@ -981,8 +1002,9 @@ Code mode coverage should prove:
- all effective non-MCP tools appear in `ALL_TOOLS`
- denied tools do not appear in `ALL_TOOLS`
- `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools
- MCP namespace `$api()` returns TypeScript-style headers inferred from MCP
schemas
- `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` expose TypeScript-style
MCP declarations without a bridge/tool call
- MCP namespace `$api()` remains available as an inline fallback for schemas
- MCP namespace calls work for visible MCP tools with one object input, while
direct MCP catalog entries are absent from `tools.*`
- Tool Search control tools are hidden from both the model surface and the hidden
@@ -1014,8 +1036,8 @@ Run these as integration or end-to-end tests when changing the runtime:
7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present.
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
`tools.describe`, and `tools.call`.
9. In `exec`, call `MCP.$api()` and `MCP.<server>.$api()` and assert the headers
describe visible MCP tools.
9. In `exec`, call `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` and
assert the declaration files describe visible MCP tools.
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and
assert direct MCP catalog entries are absent from `ALL_TOOLS` and `tools.*`.
11. Assert denied tools are absent and cannot be called by guessed id.

View File

@@ -262,7 +262,12 @@ async function terminatePids(
deps: AcpxProcessCleanupDeps | undefined,
): Promise<number[]> {
const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal));
const sleep = deps?.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
const sleep =
deps?.sleep ??
((ms) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
}));
const terminated: number[] = [];
for (const pid of pids) {
@@ -302,7 +307,7 @@ export async function cleanupOpenClawOwnedAcpxProcessTree(params: {
return { inspectedPids: [], terminatedPids: [], skippedReason: "missing-root" };
}
let processes: AcpxProcessInfo[] = [];
let processes: AcpxProcessInfo[];
try {
processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
} catch {

View File

@@ -1196,7 +1196,7 @@ export class AcpxRuntime implements AcpRuntime {
const record = await this.sessionStore.load(
input.handle.acpxRecordId ?? input.handle.sessionKey,
);
let closeSucceeded = false;
let closeSucceeded;
try {
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
handle: input.handle,

View File

@@ -2958,7 +2958,9 @@ describe("active-memory plugin", () => {
};
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => {
await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 5));
await new Promise((resolve) => {
setTimeout(resolve, (params.timeoutMs ?? 0) + 5);
});
return {
payloads: [{ text: "late timeout payload that should never become memory context" }],
meta: { aborted: true },
@@ -3001,7 +3003,9 @@ describe("active-memory plugin", () => {
};
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedAgent.mockImplementationOnce(async () => {
await new Promise((resolve) => setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5));
await new Promise((resolve) => {
setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5);
});
return { payloads: [{ text: "remember the ramen place" }] };
});
@@ -3131,7 +3135,9 @@ describe("active-memory plugin", () => {
},
},
]);
await new Promise((resolve) => setTimeout(resolve, 35));
await new Promise((resolve) => {
setTimeout(resolve, 35);
});
return { payloads: [{ text: "User usually orders ramen." }] };
});
@@ -3221,7 +3227,9 @@ describe("active-memory plugin", () => {
},
},
]);
await new Promise((resolve) => setTimeout(resolve, 35));
await new Promise((resolve) => {
setTimeout(resolve, 35);
});
return { payloads: [{ text: "User usually orders ramen after late flights." }] };
});

View File

@@ -1793,7 +1793,9 @@ function watchTerminalMemorySearchResult(params: {
if (stopped) {
return;
}
timeoutId = setTimeout(tick, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
timeoutId = setTimeout(() => {
void tick();
}, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
timeoutId.unref?.();
};
const tick = async () => {

View File

@@ -42,7 +42,9 @@ import { BrowserCdpEndpointBlockedError } from "./errors.js";
async function startWsServer() {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
return { wss, port, url: `ws://127.0.0.1:${port}/devtools/browser/TEST` };
}
@@ -55,7 +57,9 @@ describe("cdp.helpers internal", () => {
registerManagedProxyBrowserCdpBypassMock.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
if (wss) {
await new Promise<void>((resolve) => wss?.close(() => resolve()));
await new Promise<void>((resolve) => {
wss?.close(() => resolve());
});
wss = null;
}
});
@@ -307,7 +311,9 @@ describe("cdp.helpers internal", () => {
cb(true);
},
});
await new Promise<void>((resolve) => wss?.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wss?.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
let callbackCount = 0;
wss.on("connection", (socket) => {
@@ -341,7 +347,9 @@ describe("cdp.helpers internal", () => {
cb(false, 429, "too many requests");
},
});
await new Promise<void>((resolve) => wss?.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wss?.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
await expect(

View File

@@ -397,7 +397,9 @@ type CdpSocketOptions = {
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function normalizeRetryCount(value: number | undefined, fallback: number): number {

View File

@@ -79,7 +79,9 @@ function replyToViewportCommandOrScreenshot(
async function startMockWsServer(handle: CdpReplyHandler) {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
@@ -113,7 +115,9 @@ describe("cdp internal", () => {
afterEach(async () => {
if (wss) {
await new Promise<void>((resolve) => wss?.close(() => resolve()));
await new Promise<void>((resolve) => {
wss?.close(() => resolve());
});
wss = null;
}
});
@@ -1072,7 +1076,9 @@ describe("cdp internal", () => {
// in createTargetViaCdp — the bare-ws root triggers discovery.
const http = await import("node:http");
const wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wsServer.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wsServer.once("listening", () => resolve());
});
const wsPort = (wsServer.address() as { port: number }).port;
wsServer.on("connection", (socket) => {
socket.on("message", (raw) => {
@@ -1110,7 +1116,9 @@ describe("cdp internal", () => {
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => httpServer.listen(0, "127.0.0.1", () => resolve()));
await new Promise<void>((resolve) => {
httpServer.listen(0, "127.0.0.1", () => resolve());
});
const httpPort = (httpServer.address() as { port: number }).port;
try {
const out = await createTargetViaCdp({
@@ -1119,8 +1127,12 @@ describe("cdp internal", () => {
});
expect(out.targetId).toBe("T_BARE_WS");
} finally {
await new Promise<void>((resolve) => wsServer.close(() => resolve()));
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
await new Promise<void>((resolve) => {
wsServer.close(() => resolve());
});
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
}
});

View File

@@ -27,7 +27,9 @@ describe("cdp", () => {
const startWsServer = async () => {
wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wsServer?.once("listening", resolve));
await new Promise<void>((resolve) => {
wsServer?.once("listening", resolve);
});
return (wsServer.address() as { port: number }).port;
};
@@ -77,7 +79,9 @@ describe("cdp", () => {
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
return (httpServer.address() as { port: number }).port;
};
@@ -85,14 +89,16 @@ describe("cdp", () => {
vi.unstubAllEnvs();
await new Promise<void>((resolve) => {
if (!httpServer) {
return resolve();
resolve();
return;
}
httpServer.close(() => resolve());
httpServer = null;
});
await new Promise<void>((resolve) => {
if (!wsServer) {
return resolve();
resolve();
return;
}
wsServer.close(() => resolve());
wsServer = null;
@@ -190,7 +196,9 @@ describe("cdp", () => {
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
const httpPort = (httpServer.address() as AddressInfo).port;
await expect(
@@ -210,7 +218,9 @@ describe("cdp", () => {
heldSockets.push(socket);
// Hold the TCP connection open without completing the WebSocket handshake.
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) => {
httpServer?.listen(0, "127.0.0.1", resolve);
});
const port = (httpServer.address() as AddressInfo).port;
try {
@@ -507,7 +517,9 @@ describe("cdp", () => {
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
try {
const addr = server.address() as AddressInfo;
const created = await createTargetViaCdp({
@@ -516,8 +528,12 @@ describe("cdp", () => {
});
expect(created.targetId).toBe("ROOT_FALLBACK");
} finally {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});

View File

@@ -137,7 +137,7 @@ async function diagnoseCdpHealthCommand(
if (settled) {
return;
}
let parsed: { id?: unknown; result?: unknown } | null = null;
let parsed: { id?: unknown; result?: unknown } | null;
try {
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
} catch {

View File

@@ -194,8 +194,12 @@ async function withMockChromeCdpServer(params: {
const addr = server.address() as AddressInfo;
await params.run(`http://127.0.0.1:${addr.port}`);
} finally {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
}
@@ -952,9 +956,13 @@ describe("chrome.ts internal", () => {
it("resolves false when the direct-ws probe cannot connect", async () => {
// Bind a ws server and then close it, so connecting to it fails.
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await expect(
isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/GONE`, 50),
).resolves.toBe(false);
@@ -962,7 +970,9 @@ describe("chrome.ts internal", () => {
it("resolves true when the direct-ws handshake succeeds", async () => {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
const port = (wss.address() as { port: number }).port;
try {
// Direct /devtools/ WS URL — isChromeReachable goes through
@@ -972,7 +982,9 @@ describe("chrome.ts internal", () => {
isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/OK`, 500),
).resolves.toBe(true);
} finally {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
}
});
});
@@ -994,9 +1006,13 @@ describe("chrome.ts internal", () => {
// accepting ws upgrades — the canRunCdpHealthCommand probe will
// fire its 'error' handler during handshake.
const dead = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => dead.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
dead.once("listening", () => resolve());
});
const deadPort = (dead.address() as { port: number }).port;
await new Promise<void>((resolve) => dead.close(() => resolve()));
await new Promise<void>((resolve) => {
dead.close(() => resolve());
});
const server = createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
@@ -1009,14 +1025,18 @@ describe("chrome.ts internal", () => {
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
try {
const addr = server.address() as AddressInfo;
await expect(isChromeCdpReady(`http://127.0.0.1:${addr.port}`, 50, 10)).resolves.toBe(
false,
);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});

View File

@@ -42,14 +42,12 @@ async function startLoopbackCdpServer(): Promise<RunningServer> {
afterEach(async () => {
await Promise.all(
runningServers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
),
),
runningServers.splice(0).map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
),
);
});

View File

@@ -108,8 +108,12 @@ async function withMockChromeCdpServer(params: {
const addr = server.address() as AddressInfo;
await params.run(`http://127.0.0.1:${addr.port}`);
} finally {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
}
@@ -549,7 +553,9 @@ describe("browser chrome helpers", () => {
}),
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
@@ -559,7 +565,7 @@ describe("browser chrome helpers", () => {
onConnection: (wss) => {
wss.on("connection", (ws) => {
ws.on("message", (raw) => {
let message: { id?: unknown; method?: unknown } | null = null;
let message: { id?: unknown; method?: unknown } | null;
try {
const text =
typeof raw === "string"
@@ -755,8 +761,12 @@ describe("browser chrome helpers", () => {
expect(diagnostic.wsUrl).toBe(wsOnlyBase);
expect(diagnostic.browser).toBe("Browserless/Mock");
} finally {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
@@ -785,12 +795,16 @@ describe("browser chrome helpers", () => {
);
// A real WS server accepts the handshake.
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
const port = (wss.address() as AddressInfo).port;
try {
await expect(isChromeReachable(`ws://127.0.0.1:${port}`, 500)).resolves.toBe(true);
} finally {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
}
});
@@ -811,7 +825,9 @@ describe("browser chrome helpers", () => {
}
});
});
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
await new Promise<void>((resolve) => {
wss.once("listening", () => resolve());
});
const port = (wss.address() as AddressInfo).port;
try {
await expect(isChromeCdpReady(`ws://127.0.0.1:${port}`, 500, 500)).resolves.toBe(true);
@@ -820,7 +836,9 @@ describe("browser chrome helpers", () => {
);
expect(diagnostic.wsUrl).toBe(`ws://127.0.0.1:${port}`);
} finally {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
}
});

View File

@@ -519,7 +519,9 @@ export async function launchOpenClawChrome(
if (exists(localStatePath) && exists(preferencesPath)) {
break;
}
await new Promise((r) => setTimeout(r, CHROME_BOOTSTRAP_PREFS_POLL_MS));
await new Promise((r) => {
setTimeout(r, CHROME_BOOTSTRAP_PREFS_POLL_MS);
});
}
try {
bootstrap.kill("SIGTERM");
@@ -531,7 +533,9 @@ export async function launchOpenClawChrome(
if (bootstrap.exitCode != null) {
break;
}
await new Promise((r) => setTimeout(r, CHROME_BOOTSTRAP_EXIT_POLL_MS));
await new Promise((r) => {
setTimeout(r, CHROME_BOOTSTRAP_EXIT_POLL_MS);
});
}
}
@@ -577,7 +581,9 @@ export async function launchOpenClawChrome(
launchHttpReachable = true;
break;
}
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
await new Promise((r) => {
setTimeout(r, CHROME_LAUNCH_READY_POLL_MS);
});
}
if (!launchHttpReachable) {
@@ -682,7 +688,9 @@ export async function stopOpenClawChrome(
return;
}
const remainingMs = timeoutMs - (Date.now() - start);
await new Promise((r) => setTimeout(r, Math.max(1, Math.min(100, remainingMs))));
await new Promise((r) => {
setTimeout(r, Math.max(1, Math.min(100, remainingMs)));
});
}
try {

View File

@@ -37,7 +37,9 @@ describe("browser client fetch attachOnly diagnostics", () => {
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as { port: number }).port;
const configPath = path.join(tempHome.home, ".openclaw", "openclaw.json");
await fs.writeFile(
@@ -78,7 +80,9 @@ describe("browser client fetch attachOnly diagnostics", () => {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
});

View File

@@ -469,7 +469,7 @@ export function resolveProfile(
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
let cdpUrl;
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
const headless = profile.headless ?? resolved.headless;
const headlessSource =

View File

@@ -212,7 +212,9 @@ describe("pw-session ensurePageState", () => {
try {
handlers.get("download")?.[0]?.(download);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(unhandled).toStrictEqual([]);
await expect(download.path?.()).rejects.toThrow("save failed");

View File

@@ -947,7 +947,9 @@ async function connectBrowser(cdpUrl: string, ssrfPolicy?: SsrFPolicy): Promise<
break;
}
const delay = resolveCdpConnectRetryDelayMs(attempt);
await new Promise((r) => setTimeout(r, delay));
await new Promise((r) => {
setTimeout(r, delay);
});
}
}
if (lastErr instanceof Error) {
@@ -1066,7 +1068,7 @@ async function findPageByTargetId(
const pages = await getAllPages(browser);
let resolvedViaCdp = false;
for (const page of pages) {
let tid: string | null = null;
let tid: string | null;
try {
tid = await pageTargetId(page);
resolvedViaCdp = true;
@@ -1170,7 +1172,7 @@ export async function getPageForTargetId(opts: {
}
function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
let sameMainFrame = false;
let sameMainFrame;
try {
sameMainFrame = request.frame() === page.mainFrame();
} catch {
@@ -1197,7 +1199,7 @@ function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
}
function isSubframeDocumentNavigationRequest(page: Page, request: Request): boolean {
let sameMainFrame = false;
let sameMainFrame;
try {
sameMainFrame = request.frame() === page.mainFrame();
} catch {

View File

@@ -581,7 +581,9 @@ export async function clickViaPlaywright(opts: {
abortPromise,
reconcileRemoteDialog,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}
if (opts.doubleClick) {
await awaitActionWithAbort(

View File

@@ -45,7 +45,9 @@ import type { BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, jsonError, toStringOrEmpty } from "./utils.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
const EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS = [0, 250, 500] as const;

View File

@@ -34,7 +34,9 @@ export async function resolveTargetIdAfterNavigate(opts: {
const first = pickReplacement(await opts.listTabs());
currentTargetId = first.targetId;
if (first.shouldRetry) {
await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 800));
await new Promise((r) => {
setTimeout(r, opts.retryDelayMs ?? 800);
});
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
}).targetId;

View File

@@ -286,7 +286,9 @@ export function createProfileAvailability({
if (await isReachable(attemptTimeoutMs)) {
return;
}
await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS));
await new Promise((r) => {
setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS);
});
}
throw new Error(
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start. ${await describeCdpFailure(
@@ -306,7 +308,9 @@ export function createProfileAvailability({
} catch (err) {
lastError = err;
}
await new Promise((r) => setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS));
await new Promise((r) => {
setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS);
});
}
throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError));
};

View File

@@ -350,7 +350,9 @@ export function createProfileTabOps({
triggerManagedTabLimit(found.targetId);
return assignTabAlias({ profileState, tab: found, label: opts?.label });
}
await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));
await new Promise((r) => {
setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS);
});
}
triggerManagedTabLimit(createdViaCdp);
return assignTabAlias({

View File

@@ -21,7 +21,9 @@ function isTransientStartupFetchError(error: unknown): boolean {
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function postStartWithRetry(params: {

View File

@@ -41,7 +41,9 @@ describe("browser control HTTP auth", () => {
if (!current) {
return;
}
await new Promise<void>((resolve) => current.close(() => resolve()));
await new Promise<void>((resolve) => {
current.close(() => resolve());
});
});
it("requires bearer auth for standalone browser HTTP routes", async () => {

View File

@@ -150,7 +150,7 @@ function formatDoctorLine(check: BrowserDoctorCheck): string {
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
const checks: BrowserDoctorCheck[] = [];
let status: BrowserStatus | null = null;
let status: BrowserStatus | null;
try {
status = await fetchBrowserStatus(parent, profile);

View File

@@ -171,7 +171,7 @@ export async function handleBrowserGatewayRequest({
}
const cfg = getRuntimeConfig();
let nodeTarget: NodeSession | null = null;
let nodeTarget: NodeSession | null;
try {
nodeTarget = resolveBrowserNodeTarget({
cfg,

View File

@@ -388,7 +388,6 @@ describe("canvas host", () => {
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
const linkPath = path.join(a2uiRoot, linkName);
let createdBundle = false;
let createdLink = false;
try {
await fs.stat(bundlePath);
@@ -398,7 +397,6 @@ describe("canvas host", () => {
}
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
createdLink = true;
try {
const res = await captureA2uiResponse(`${A2UI_PATH}/`);
@@ -421,9 +419,7 @@ describe("canvas host", () => {
expect(symlinkRes.status).toBe(404);
expect(symlinkRes.body).toBe("not found");
} finally {
if (createdLink) {
await fs.rm(linkPath, { force: true });
}
await fs.rm(linkPath, { force: true });
if (createdBundle) {
await fs.rm(bundlePath, { force: true });
}

View File

@@ -443,7 +443,9 @@ export async function createCanvasHostHandler(
}
}
if (wss) {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
}
},
};
@@ -528,9 +530,9 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva
if (ownsHandler) {
await handler.close();
}
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
},
};
}

View File

@@ -121,7 +121,9 @@ describe("ClickClack gateway", () => {
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
socket.emit("message", Buffer.from("{not json"));
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(runError).toBeUndefined();
expect(ctx.log?.warn).toHaveBeenCalledWith(
"[default] skipped malformed ClickClack websocket event",

View File

@@ -190,7 +190,9 @@ export async function startClickClackGatewayAccount(
socket.on("error", reject);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
await new Promise((resolve) => {
setTimeout(resolve, account.reconnectMs);
});
}
}
ctx.setStatus({ accountId: account.accountId, running: false });

View File

@@ -790,7 +790,9 @@ async function waitForFile(filePath: string): Promise<string> {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
}
throw new Error(`timed out waiting for ${filePath}`);
@@ -838,10 +840,14 @@ describe("connectCodexAppServerEndpoint", () => {
await expect(
Promise.race([
probe,
new Promise((_, reject) => setTimeout(() => reject(new Error("probe timed out")), 500)),
new Promise((_, reject) => {
setTimeout(() => reject(new Error("probe timed out")), 500);
}),
]),
).resolves.toMatchObject([{ endpointId: "ws", ok: false }]);
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
it("rejects malformed stdio frames instead of throwing out of band", async () => {
@@ -930,7 +936,9 @@ describe("connectCodexAppServerEndpoint", () => {
);
await expect(supervisor.probeEndpoints()).resolves.toEqual([{ endpointId: "exits", ok: true }]);
await new Promise((resolve) => setTimeout(resolve, 50));
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await expect(supervisor.probeEndpoints()).resolves.toMatchObject([
{
endpointId: "exits",

View File

@@ -337,7 +337,6 @@ export async function startCodexAttemptThread(params: {
if (startupClientForAbandonedRequestCleanup === failedClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
attemptedClient = undefined;
if (attempt >= CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS) {
embeddedAgentLog.warn(
"codex app-server connection closed during startup; retries exhausted",

View File

@@ -147,7 +147,7 @@ describe("Codex app-server attempt timeouts", () => {
}, 5);
});
},
operation: async () => new Promise<never>(() => undefined),
operation: async () => new Promise<never>(() => {}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
@@ -164,7 +164,7 @@ describe("Codex app-server attempt timeouts", () => {
const run = withCodexStartupTimeout({
timeoutMs: 1_000,
signal: controller.signal,
operation: async () => new Promise<never>(() => undefined),
operation: async () => new Promise<never>(() => {}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup aborted");

View File

@@ -486,7 +486,7 @@ describe("CodexAppServerClient", () => {
clients.push(harness.client);
harness.client.addRequestHandler((request) => {
if (request.method === "item/tool/call") {
return new Promise<never>(() => undefined);
return new Promise<never>(() => {});
}
return undefined;
});

View File

@@ -179,6 +179,32 @@ describe("Codex app-server dynamic tool build", () => {
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
});
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
const messageTool = createRuntimeDynamicTool("message");
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {
get(target, property, receiver) {
if (property === "0") {
throw new Error("fuzzplugin tool entry getter exploded");
}
if (property === "1") {
return messageTool;
}
if (property === "length") {
return 2;
}
return Reflect.get(target, property, receiver);
},
});
setOpenClawCodingToolsFactoryForTests(() => sourceTools);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
});
it("limits Codex memory flush runs to managed read and write tools", async () => {
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {

View File

@@ -2,6 +2,7 @@ import {
buildAgentHookContextChannelFields,
buildEmbeddedAttemptToolRunContext,
embeddedAgentLog,
filterProviderNormalizableTools,
isSubagentSessionKey,
normalizeAgentRuntimeTools,
resolveAttemptSpawnWorkspaceDir,
@@ -9,6 +10,7 @@ import {
resolveSandboxContext,
supportsModelTools,
type EmbeddedRunAttemptParams,
type RuntimeToolSchemaDiagnostic,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
@@ -265,15 +267,19 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
},
});
toolBuildStages.mark("create-openclaw-coding-tools");
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
const readableAllTools = [...readableAllToolProjection.tools];
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
addSandboxShellDynamicToolsIfAvailable(
isCodexMemoryFlushRun(params)
? filterCodexMemoryFlushDynamicTools(allTools)
: filterCodexDynamicTools(allTools, input.pluginConfig),
allTools,
? filterCodexMemoryFlushDynamicTools(readableAllTools)
: filterCodexDynamicTools(readableAllTools, input.pluginConfig),
readableAllTools,
input,
),
allTools,
readableAllTools,
input,
);
toolBuildStages.mark("codex-filtering");
@@ -295,8 +301,25 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
preNormalizationDiagnostics.push(...diagnostics),
});
toolBuildStages.mark("runtime-normalization");
if (preNormalizationDiagnostics.length > 0) {
embeddedAgentLog.warn(
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
{
runId: params.runId,
sessionId: params.sessionId,
diagnostics: preNormalizationDiagnostics.map((diagnostic) => ({
index: diagnostic.toolIndex,
tool: diagnostic.toolName,
violations: diagnostic.violations.slice(0, 12),
violationCount: diagnostic.violations.length,
})),
},
);
}
const summary = toolBuildStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
@@ -308,7 +331,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
phase,
totalMs: summary.totalMs,
stages: summary.stages,
allToolCount: allTools.length,
allToolCount: readableAllTools.length,
codexFilteredToolCount: codexFilteredTools.length,
visionFilteredToolCount: visionFilteredTools.length,
filteredToolCount: filteredTools.length,

View File

@@ -194,7 +194,7 @@ describe("dynamic tool execution helpers", () => {
toolBridge: {
handleToolCall: vi.fn((_call, options) => {
capturedSignal = options?.signal;
return new Promise<never>(() => undefined);
return new Promise<never>(() => {});
}),
},
signal: new AbortController().signal,
@@ -230,7 +230,7 @@ describe("dynamic tool execution helpers", () => {
arguments: { action: "poll", sessionId: "process-session", timeout: 30_000 },
},
toolBridge: {
handleToolCall: vi.fn(() => new Promise<never>(() => undefined)),
handleToolCall: vi.fn(() => new Promise<never>(() => {})),
},
signal: new AbortController().signal,
timeoutMs: 1,

View File

@@ -35,7 +35,9 @@ const tinyPngBase64 =
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
function flushDiagnosticEvents() {
return new Promise<void>((resolve) => setImmediate(resolve));
return new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
function assistantMessage(text: string, timestamp: number) {

View File

@@ -52,8 +52,30 @@ function createRuntime() {
error?: string;
}>;
};
const createRunningTaskRun = vi.fn(
(params): AgentHarnessTaskRecord => ({
taskId: params.sourceId ?? params.runId,
runtime: "subagent",
sourceId: params.sourceId,
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
agentId: params.agentId,
runId: params.runId,
label: params.label,
task: params.task,
status: "running",
deliveryStatus: params.deliveryStatus ?? "not_applicable",
notifyPolicy: params.notifyPolicy ?? "silent",
createdAt: params.startedAt ?? Date.now(),
startedAt: params.startedAt,
lastEventAt: params.lastEventAt,
progressSummary: params.progressSummary,
}),
);
const taskRuntime = {
createRunningTaskRun: vi.fn(),
createRunningTaskRun,
tryCreateRunningTaskRun: vi.fn((params) => createRunningTaskRun(params)),
recordTaskRunProgressByRunId: vi.fn(() => []),
finalizeTaskRunByRunId: vi.fn(() => []),
listTaskRecords: vi.fn((): AgentHarnessTaskRecord[] => []),

View File

@@ -7,7 +7,7 @@ import {
function createRuntime() {
return {
createRunningTaskRun: vi.fn(),
tryCreateRunningTaskRun: vi.fn((params) => ({ taskId: "task-native-subagent", ...params })),
recordTaskRunProgressByRunId: vi.fn(() => []),
finalizeTaskRunByRunId: vi.fn(() => []),
} as unknown as TaskLifecycleRuntime;
@@ -49,7 +49,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith({
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
sourceId: "codex-thread:child-thread",
agentId: "main",
runId: "codex-thread:child-thread",
@@ -62,7 +62,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
lastEventAt: 20_000,
progressSummary: "Codex native subagent started.",
});
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
expect(vi.mocked(runtime.tryCreateRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
"childSessionKey",
);
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
@@ -99,7 +99,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.createRunningTaskRun).not.toHaveBeenCalled();
expect(runtime.tryCreateRunningTaskRun).not.toHaveBeenCalled();
expect(runtime.recordTaskRunProgressByRunId).not.toHaveBeenCalled();
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
});
@@ -133,7 +133,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
mirror.handleNotification(notification);
mirror.handleNotification(notification);
expect(runtime.createRunningTaskRun).toHaveBeenCalledTimes(1);
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(1);
});
it("maps Codex thread status changes onto the mirrored task run", () => {
@@ -228,7 +228,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith({
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
sourceId: "codex-thread:child-thread",
runId: "codex-thread:child-thread",
label: "Codex subagent",
@@ -240,7 +240,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
lastEventAt: 40_000,
progressSummary: "Codex native subagent spawned.",
});
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
expect(vi.mocked(runtime.tryCreateRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
"childSessionKey",
);
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
@@ -282,7 +282,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith(
expect.objectContaining({
runId: "codex-thread:child-thread",
task: "inspect one thing",
@@ -319,7 +319,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
},
});
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith(
expect.objectContaining({
runId: "codex-thread:child-thread",
task: "inspect one thing",

View File

@@ -15,7 +15,7 @@ import { isJsonObject } from "./protocol.js";
export type TaskLifecycleRuntime = Pick<
AgentHarnessTaskRuntime,
"createRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
export type CodexNativeSubagentTaskMirrorParams = {
@@ -27,6 +27,7 @@ export type CodexNativeSubagentTaskMirrorParams = {
export class CodexNativeSubagentTaskMirror {
private readonly mirroredThreadIds = new Set<string>();
private readonly failedMirrorThreadIds = new Set<string>();
private readonly terminalRunIds = new Set<string>();
private readonly now: () => number;
@@ -81,7 +82,7 @@ export class CodexNativeSubagentTaskMirror {
trimOptional(thread.preview) ??
`Codex native subagent${label === "Codex subagent" ? "" : ` ${label}`}`;
const createdAt = secondsToMillis(thread.createdAt) ?? this.now();
this.runtime.createRunningTaskRun({
const taskRecord = this.runtime.tryCreateRunningTaskRun({
sourceId: runId,
agentId: this.params.agentId,
runId,
@@ -94,6 +95,13 @@ export class CodexNativeSubagentTaskMirror {
lastEventAt: this.now(),
progressSummary: "Codex native subagent started.",
});
if (!taskRecord) {
this.mirroredThreadIds.delete(threadId);
this.failedMirrorThreadIds.add(threadId);
return;
}
this.failedMirrorThreadIds.delete(threadId);
this.terminalRunIds.delete(runId);
this.applyStatus(threadId, thread.status);
}
@@ -106,6 +114,9 @@ export class CodexNativeSubagentTaskMirror {
}
private applyStatus(threadId: string, status: CodexThreadStatus | null | undefined): void {
if (!this.mirroredThreadIds.has(threadId) && this.failedMirrorThreadIds.has(threadId)) {
return;
}
const statusType = status?.type;
if (!statusType) {
return;
@@ -219,7 +230,7 @@ export class CodexNativeSubagentTaskMirror {
const prompt = trimOptional(readString(item, "prompt"));
const runId = codexNativeSubagentRunId(normalizedThreadId);
const createdAt = this.now();
this.runtime.createRunningTaskRun({
const taskRecord = this.runtime.tryCreateRunningTaskRun({
sourceId: runId,
agentId: this.params.agentId,
runId,
@@ -232,6 +243,13 @@ export class CodexNativeSubagentTaskMirror {
lastEventAt: createdAt,
progressSummary: "Codex native subagent spawned.",
});
if (!taskRecord) {
this.mirroredThreadIds.delete(normalizedThreadId);
this.failedMirrorThreadIds.add(normalizedThreadId);
return;
}
this.failedMirrorThreadIds.delete(normalizedThreadId);
this.terminalRunIds.delete(runId);
}
private applyCollabAgentStatus(
@@ -239,6 +257,9 @@ export class CodexNativeSubagentTaskMirror {
status: string | undefined,
message: string | null | undefined,
): void {
if (!this.mirroredThreadIds.has(threadId) && this.failedMirrorThreadIds.has(threadId)) {
return;
}
const normalizedStatus = normalizeAgentStateStatus(status);
if (!normalizedStatus) {
return;

View File

@@ -85,7 +85,9 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
}
await Promise.race([
Promise.allSettled(attempts.map((attempt) => attempt.promise)),
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
new Promise<void>((resolve) => {
setTimeout(resolve, 5_000);
}),
]);
}

View File

@@ -66,7 +66,9 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
expect(llmInput).toHaveBeenCalled();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
const [llmInputPayload, llmInputContext] = mockCall(llmInput, "llm_input") as [
{

View File

@@ -817,7 +817,7 @@ describe("runCodexAppServerAttempt", () => {
onTimeout: async () => {
await releaseCodexSandboxExecServerEnvironment(sandbox);
},
operation: async () => new Promise<never>(() => undefined),
operation: async () => new Promise<never>(() => {}),
}),
).rejects.toThrow("codex app-server startup timed out");
@@ -1111,7 +1111,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
@@ -1684,7 +1686,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -1725,7 +1729,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -1762,7 +1768,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -1801,7 +1809,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;
@@ -1846,7 +1856,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;
@@ -1891,7 +1903,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;
@@ -2134,7 +2148,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
@@ -2189,7 +2205,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
@@ -2266,7 +2284,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
@@ -2454,7 +2474,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
@@ -2483,7 +2505,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -2526,7 +2550,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -2566,7 +2592,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
@@ -2609,7 +2637,9 @@ describe("runCodexAppServerAttempt", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -2851,7 +2881,9 @@ describe("runCodexAppServerAttempt", () => {
const result = await run;
expect(result.aborted).toBe(true);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(unhandledRejections).toStrictEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
@@ -2943,7 +2975,9 @@ describe("runCodexAppServerAttempt", () => {
{ turnTerminalIdleTimeoutMs: 60_000 },
);
await bufferedTerminal;
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
harness.close();
const result = await run;
@@ -2983,7 +3017,9 @@ describe("runCodexAppServerAttempt", () => {
turnTerminalIdleTimeoutMs: 60_000,
});
await harness.waitForMethod("turn/start");
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -3076,7 +3112,9 @@ describe("runCodexAppServerAttempt", () => {
},
},
});
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(resolved).toBe(false);
await harness.notify({
@@ -3120,7 +3158,9 @@ describe("runCodexAppServerAttempt", () => {
},
},
});
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(resolved).toBe(false);
expect(
warn.mock.calls.some(([message]) =>
@@ -3800,7 +3840,7 @@ describe("runCodexAppServerAttempt", () => {
});
it("times out app-server startup before thread setup can hang forever", async () => {
setCodexAppServerClientFactoryForTest(() => new Promise<never>(() => undefined));
setCodexAppServerClientFactoryForTest(() => new Promise<never>(() => {}));
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
@@ -3834,7 +3874,9 @@ describe("runCodexAppServerAttempt", () => {
interval: 1,
});
await waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -4307,7 +4349,7 @@ describe("runCodexAppServerAttempt", () => {
const c = {
request: vi.fn(async (method: string) => {
if (method === "thread/start") {
return await new Promise<never>(() => undefined);
return await new Promise<never>(() => {});
}
return {};
}),
@@ -4502,7 +4544,9 @@ describe("runCodexAppServerAttempt", () => {
interval: 1,
});
await waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;

View File

@@ -965,8 +965,19 @@ export async function runCodexAppServerAttempt(
let client: CodexAppServerClient;
let thread: CodexAppServerThreadLifecycleBinding;
let trajectoryEndRecorded = false;
const markTrajectoryEndRecorded = () => {
trajectoryEndRecorded = true;
};
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
let releaseSharedClientLease: (() => void) | undefined;
const releaseSharedClientLeaseOnce = () => {
const release = releaseSharedClientLease;
if (!release) {
return;
}
releaseSharedClientLease = undefined;
release();
};
let sandboxExecEnvironmentAcquired = false;
const releaseSandboxExecEnvironment = async () => {
if (sandboxExecEnvironmentAcquired) {
@@ -1914,7 +1925,7 @@ export async function runCodexAppServerAttempt(
aborted: runAbortController.signal.aborted,
promptError: turnStartErrorMessage,
});
trajectoryEndRecorded = true;
markTrajectoryEndRecorded();
runAgentHarnessLlmOutputHook({
event: {
runId: params.runId,
@@ -1979,8 +1990,7 @@ export async function runCodexAppServerAttempt(
},
});
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
releaseSharedClientLeaseOnce();
if (usageLimitError) {
await markCodexAuthProfileBlockedFromRateLimits({
params,
@@ -2000,8 +2010,7 @@ export async function runCodexAppServerAttempt(
}
}
if (!turn) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
releaseSharedClientLeaseOnce();
throw new Error("codex app-server turn/start failed without an error");
}
turnIdRef.current = turn.turn.id;
@@ -2250,7 +2259,7 @@ export async function runCodexAppServerAttempt(
yieldDetected,
promptError: normalizeCodexTrajectoryError(finalPromptError),
});
trajectoryEndRecorded = true;
markTrajectoryEndRecorded();
await mirrorTranscriptBestEffort({
params,
agentId: sessionAgentId,
@@ -2427,7 +2436,7 @@ export async function runCodexAppServerAttempt(
notificationCleanup();
requestCleanup();
closeCleanup?.();
releaseSharedClientLease?.();
releaseSharedClientLeaseOnce();
if (nativeHookRelay) {
if (shouldDelayNativeHookRelayUnregister) {
// Codex hook subprocesses can outlive a completed app-server turn by a

View File

@@ -71,7 +71,9 @@ describe("createCodexAttemptTurnWatchController", () => {
try {
controller.armAttemptIdleWatch();
controller.touchActivity("turn:start", { attemptProgress: true });
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
controller.noteNotificationReceived("response.output_text.delta", {
attemptProgress: true,
attemptTimeoutMs: 40,
@@ -405,7 +407,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
return turnStartResult("turn-1", "inProgress");
}
if (method === "turn/interrupt") {
return new Promise<never>(() => undefined);
return new Promise<never>(() => {});
}
return {};
});
@@ -474,7 +476,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
fastWait,
);
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => {
setTimeout(resolve, 60);
});
await harness.notify({
method: "rawResponseItem/completed",
params: {
@@ -488,7 +492,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
},
});
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => {
setTimeout(resolve, 60);
});
await harness.notify({
method: "rawResponseItem/completed",
params: {
@@ -543,7 +549,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
fastWait,
);
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => {
setTimeout(resolve, 60);
});
await harness.handleServerRequest({
id: "request-account-refresh",
method: "account/nonTurnRefresh",
@@ -595,7 +603,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
});
await harness.waitForMethod("turn/start");
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => {
setTimeout(resolve, 60);
});
void harness.handleServerRequest({
id: "request-auth-refresh",
method: "account/chatgptAuthTokens/refresh",
@@ -659,7 +669,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
fastWait,
);
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => {
setTimeout(resolve, 60);
});
await harness.handleServerRequest({
id: "request-null-turn-elicitation",
method: "mcpServer/elicitation/request",
@@ -673,7 +685,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
_meta: null,
},
});
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => {
setTimeout(resolve, 60);
});
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -735,7 +749,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
),
fastWait,
);
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => {
setTimeout(resolve, 60);
});
expect(
onRunProgress.mock.calls.some(
([event]) =>
@@ -788,7 +804,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
fastWait,
);
await new Promise((resolve) => setTimeout(resolve, 75));
await new Promise((resolve) => {
setTimeout(resolve, 75);
});
const response = harness.handleServerRequest({
id: "request-user-input",
method: "item/tool/requestUserInput",
@@ -812,7 +830,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1), fastWait);
await new Promise((resolve) => setTimeout(resolve, 125));
await new Promise((resolve) => {
setTimeout(resolve, 125);
});
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
expect(queueActiveRunMessageForTest("session-1", "2")).toBe(true);
@@ -843,7 +863,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
});
await harness.waitForMethod("turn/start");
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => {
setTimeout(resolve, 60);
});
await harness.handleServerRequest({
id: "request-foreign-elicitation",
method: "mcpServer/elicitation/request",
@@ -1052,7 +1074,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(settled).toBe(false);
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
@@ -1158,7 +1182,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(settled).toBe(false);
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
@@ -1258,7 +1284,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
await notify({
@@ -1342,7 +1370,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
})) as { success?: boolean };
expect(toolResult.success).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 130));
await new Promise((resolve) => {
setTimeout(resolve, 130);
});
expect(settled).toBe(false);
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
@@ -1406,7 +1436,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 130));
await new Promise((resolve) => {
setTimeout(resolve, 130);
});
expect(settled).toBe(false);
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
@@ -1486,7 +1518,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
fastWait,
);
await new Promise((resolve) => setTimeout(resolve, 130));
await new Promise((resolve) => {
setTimeout(resolve, 130);
});
expect(settled).toBe(false);
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
@@ -1679,7 +1713,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(settled).toBe(false);
const result = await run;
@@ -1793,7 +1829,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 40));
await new Promise((resolve) => {
setTimeout(resolve, 40);
});
expect(settled).toBe(false);
const result = await run;
@@ -1884,7 +1922,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 30));
await new Promise((resolve) => {
setTimeout(resolve, 30);
});
// This covers the future-compatible path for raw response deltas if Codex
// app-server exposes them directly; current Codex primarily emits
// rawResponseItem/completed for the raw-event surface.
@@ -1896,7 +1936,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
},
});
await new Promise((resolve) => setTimeout(resolve, 30));
await new Promise((resolve) => {
setTimeout(resolve, 30);
});
expect(settled).toBe(false);
await notify({
@@ -1989,7 +2031,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 30));
await new Promise((resolve) => {
setTimeout(resolve, 30);
});
await notify({
method: "item/fileChange/patchUpdated",
params: {
@@ -2096,7 +2140,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 30));
await new Promise((resolve) => {
setTimeout(resolve, 30);
});
await notify({
method: "response.custom_tool_call_input.delta",
params: {
@@ -2194,7 +2240,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 40));
await new Promise((resolve) => {
setTimeout(resolve, 40);
});
await notify({
method: "response.custom_tool_call_input.delta",
params: {
@@ -2597,7 +2645,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 25));
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
expect(settled).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -2650,7 +2700,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 25));
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
expect(settled).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -2686,7 +2738,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(settled).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -2740,7 +2794,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(settled).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
@@ -2763,7 +2819,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
const run = runCodexAppServerAttempt(params, { turnCompletionIdleTimeoutMs: 15 });
await harness.waitForMethod("turn/start");
await harness.notify(rateLimitsUpdated(Date.now() + 60_000));
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
const result = await run;
expect({
@@ -2880,7 +2938,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
const queuedTerminal = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
void queuedTerminal.catch(() => undefined);
await new Promise((resolve) => setTimeout(resolve, 30));
await new Promise((resolve) => {
setTimeout(resolve, 30);
});
expect(settled).toBe(false);
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
@@ -3191,7 +3251,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
await notify({
@@ -3272,7 +3334,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
await notify({
@@ -3433,7 +3497,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
await notify({
@@ -3677,7 +3743,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
harness.close();
const result = await run;
@@ -3745,7 +3813,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
},
});
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(resolved).toBe(false);
await harness.notify({

View File

@@ -206,7 +206,9 @@ export async function waitForHttpBodyDeltas(
if (deltas.length >= count) {
return deltas;
}
await new Promise((resolve) => setTimeout(resolve, 25));
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
throw new Error(`expected ${count} http body deltas`);
}

View File

@@ -704,7 +704,9 @@ describe("shared Codex app-server client", () => {
});
try {
await new Promise<void>((resolve) => server.once("listening", resolve));
await new Promise<void>((resolve) => {
server.once("listening", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected websocket test server port");
@@ -741,9 +743,9 @@ describe("shared Codex app-server client", () => {
expect(authHeaders).toEqual(["Bearer tok-first", "Bearer tok-second"]);
} finally {
clearSharedCodexAppServerClient();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
});

View File

@@ -54,9 +54,14 @@ const { testing, runCodexAppServerSideQuestion } = await import("./side-question
type ServerRequest = Required<Pick<RpcRequest, "id" | "method">> & {
params?: RpcRequest["params"];
};
type ClientRequest = (
method: string,
requestParams?: unknown,
options?: unknown,
) => Promise<unknown>;
type FakeClient = {
request: ReturnType<typeof vi.fn>;
request: ReturnType<typeof vi.fn<ClientRequest>>;
addNotificationHandler: ReturnType<typeof vi.fn>;
addRequestHandler: ReturnType<typeof vi.fn>;
notifications: Array<(notification: CodexServerNotification) => void>;
@@ -71,7 +76,7 @@ function createFakeClient(): FakeClient {
const client: FakeClient = {
notifications,
requests,
request: vi.fn(),
request: vi.fn<ClientRequest>(),
addNotificationHandler: vi.fn((handler: (notification: CodexServerNotification) => void) => {
notifications.push(handler);
return () => {
@@ -136,7 +141,9 @@ function mockCall(mock: ReturnType<typeof vi.fn>, index = 0): unknown[] {
}
function flushDiagnosticEvents() {
return new Promise<void>((resolve) => setImmediate(resolve));
return new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
function activeDiagnosticToolKeys(events: DiagnosticEventPayload[]): Set<string> {
@@ -625,19 +632,21 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
approvalResponse = await client.handleRequest({
id: 42,
method: "item/commandExecution/requestApproval",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "cmd-side",
command: "/bin/bash -lc 'node -v'",
cwd: "/tmp/workspace",
},
});
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
setTimeout(() => {
void (async () => {
approvalResponse = await client.handleRequest({
id: 42,
method: "item/commandExecution/requestApproval",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "cmd-side",
command: "/bin/bash -lc 'node -v'",
cwd: "/tmp/workspace",
},
});
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
@@ -913,20 +922,22 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
toolResponse = await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
setTimeout(() => {
void (async () => {
toolResponse = await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
@@ -966,20 +977,22 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
setTimeout(() => {
void (async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
@@ -1045,20 +1058,22 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
setTimeout(() => {
void (async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
@@ -1098,35 +1113,37 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
unrelatedUserInputResponse = await client.handleRequest({
id: 42,
method: "item/tool/requestUserInput",
params: {
threadId: "parent-thread",
turnId: "parent-turn",
itemId: "input-parent",
questions: [],
},
});
userInputResponse = await client.handleRequest({
id: 43,
method: "item/tool/requestUserInput",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "input-1",
questions: [
{
id: "choice",
header: "Choice",
question: "Pick one",
options: [{ label: "A", description: "" }],
},
],
},
});
client.emit(turnCompleted("side-thread", "turn-1", "No input needed."));
setTimeout(() => {
void (async () => {
unrelatedUserInputResponse = await client.handleRequest({
id: 42,
method: "item/tool/requestUserInput",
params: {
threadId: "parent-thread",
turnId: "parent-turn",
itemId: "input-parent",
questions: [],
},
});
userInputResponse = await client.handleRequest({
id: 43,
method: "item/tool/requestUserInput",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "input-1",
questions: [
{
id: "choice",
header: "Choice",
question: "Pick one",
options: [{ label: "A", description: "" }],
},
],
},
});
client.emit(turnCompleted("side-thread", "turn-1", "No input needed."));
})();
}, 0);
return turnStartResult("turn-1");
}

View File

@@ -12,14 +12,12 @@ describe("Codex app-server websocket transport", () => {
}
clients.length = 0;
await Promise.all(
servers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
),
),
servers.splice(0).map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
),
);
});
@@ -42,7 +40,9 @@ describe("Codex app-server websocket transport", () => {
}
});
});
await new Promise<void>((resolve) => server.once("listening", resolve));
await new Promise<void>((resolve) => {
server.once("listening", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected websocket test server port");

View File

@@ -1096,7 +1096,9 @@ describe("codex conversation binding", () => {
},
{ timeoutMs: 50 },
);
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(result).toEqual({
handled: true,

View File

@@ -387,7 +387,9 @@ function isCodexPluginLoadWarningItem(item: MigrationItem): boolean {
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function buildTargetCodexPluginAppCacheKey(ctx: MigrationProviderContext): Promise<string> {

View File

@@ -440,7 +440,9 @@ async function waitForLocalHistory(params: {
}
const pollDelayMs = resolveComfyRemainingMs(deadline, params.timeoutMs, params.pollIntervalMs);
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
await new Promise((resolve) => {
setTimeout(resolve, pollDelayMs);
});
}
}
@@ -479,7 +481,9 @@ async function waitForCloudCompletion(params: {
}
const pollDelayMs = resolveComfyRemainingMs(deadline, params.timeoutMs, params.pollIntervalMs);
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
await new Promise((resolve) => {
setTimeout(resolve, pollDelayMs);
});
}
}

View File

@@ -55,7 +55,9 @@ function createDeferred<T>() {
}
async function flushAsyncWork() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
describe("createCopilotAgentHarness", () => {

View File

@@ -53,16 +53,17 @@ type SessionEventShape = {
timestamp: string;
type: string;
};
type SendAndWaitFn = (options?: unknown) => Promise<SessionEventShape | undefined>;
type FakeSession = {
abort: ReturnType<typeof vi.fn>;
abort: ReturnType<typeof vi.fn<() => Promise<void>>>;
cfg: Record<string, unknown>;
disconnect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn<() => Promise<void>>>;
emit: (eventType: string, data: Record<string, unknown>) => void;
id: string;
off: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
sendAndWait: ReturnType<typeof vi.fn>;
sendAndWait: ReturnType<typeof vi.fn<SendAndWaitFn>>;
sessionId: string;
};
@@ -129,9 +130,9 @@ function makeAssistantMessageEvent(
function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSession {
const listeners = new Map<string, Array<(event: SessionEventShape) => void>>();
return {
abort: vi.fn(async () => undefined),
abort: vi.fn<() => Promise<void>>(async () => undefined),
cfg,
disconnect: vi.fn(async () => undefined),
disconnect: vi.fn<() => Promise<void>>(async () => undefined),
emit: (eventType: string, data: Record<string, unknown>) => {
const event = makeEvent(eventType, data);
for (const listener of listeners.get(eventType) ?? []) {
@@ -151,7 +152,7 @@ function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSessio
handlers.push(handler);
listeners.set(eventType, handlers);
}),
sendAndWait: vi.fn(async () => makeAssistantMessageEvent()),
sendAndWait: vi.fn<SendAndWaitFn>(async () => makeAssistantMessageEvent()),
sessionId: id,
};
}
@@ -728,7 +729,9 @@ describe("runCopilotAttempt", () => {
});
const session = await sessionCreated.promise;
for (let i = 0; i < 100 && session.sendAndWait.mock.calls.length === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
expect(session.sendAndWait).toHaveBeenCalledTimes(1);

View File

@@ -420,7 +420,7 @@ export function convertOpenClawToolToSdkTool(
);
}
let preparedArgs = args;
let preparedArgs;
try {
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
} catch (error: unknown) {

View File

@@ -346,7 +346,9 @@ async function emitAndCaptureLog(
}
function flushDiagnosticEvents() {
return new Promise<void>((resolve) => setImmediate(resolve));
return new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
function emitTrustedModelCallCompletedWithContent(
@@ -3297,24 +3299,26 @@ describe("diagnostics-otel service", () => {
},
{
inputMessages: [
{ role: "user", content: "what changed?", timestamp: 1 },
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "lookup", arguments: { q: "trace" } }],
},
{ role: "toolResult", toolCallId: "call-1", content: { rows: 1 } },
],
{ role: "user", content: "what changed?", timestamp: 1 },
{
role: "assistant",
content: [
{ type: "toolCall", id: "call-1", name: "lookup", arguments: { q: "trace" } },
],
},
{ role: "toolResult", toolCallId: "call-1", content: { rows: 1 } },
],
outputMessages: [
{
role: "assistant",
content: [{ type: "text", text: "the trace changed" }],
stopReason: "stop",
},
],
{
role: "assistant",
content: [{ type: "text", text: "the trace changed" }],
stopReason: "stop",
},
],
systemPrompt: "be exact",
toolDefinitions: [
{ name: "lookup", description: "Lookup data", parameters: { type: "object" } },
],
{ name: "lookup", description: "Lookup data", parameters: { type: "object" } },
],
},
);
await flushDiagnosticEvents();

View File

@@ -478,8 +478,8 @@ export function normalizeCompatibilityConfig({
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
let updated;
let changed;
const bindingsToAdd: AgentBindingConfig[] = [];
const aliases = normalizeLegacyChannelAliases({

View File

@@ -298,7 +298,7 @@ export function createDiscordAutoPresenceController(params: {
let lastAppliedAt = 0;
const runEvaluation = (options?: { force?: boolean }) => {
let decision: DiscordAutoPresenceDecision | null = null;
let decision: DiscordAutoPresenceDecision | null;
try {
decision = resolveDiscordAutoPresenceDecision({
discordConfig: params.discordConfig,

View File

@@ -256,7 +256,7 @@ export function createDiscordDraftPreviewController(params: {
);
}
const alreadyStarted = progressDraftGate.hasStarted;
let progressActive = false;
let progressActive;
if (shouldStartDiscordProgressDraftNow(line)) {
await progressDraftGate.startNow();
progressActive = progressDraftGate.hasStarted;

View File

@@ -146,7 +146,7 @@ function copyRuntimeMessageFields(source: Message, target: Message): void {
}
function shouldHydrateDiscordMessage(params: { message: Message }) {
let currentText = "";
let currentText;
try {
currentText = resolveDiscordMessageText(params.message, {
includeForwarded: true,

View File

@@ -1154,9 +1154,13 @@ describe("processDiscordMessage ack reactions", () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onCompactionStart?.();
await new Promise((resolve) => setTimeout(resolve, 1_000));
await new Promise((resolve) => {
setTimeout(resolve, 1_000);
});
await params?.replyOptions?.onCompactionEnd?.();
await new Promise((resolve) => setTimeout(resolve, 1_000));
await new Promise((resolve) => {
setTimeout(resolve, 1_000);
});
return createNoQueuedDispatchResult();
});
@@ -1545,7 +1549,9 @@ describe("processDiscordMessage session routing", () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.();
await new Promise((resolve) => setTimeout(resolve, 1_000));
await new Promise((resolve) => {
setTimeout(resolve, 1_000);
});
return createNoQueuedDispatchResult();
});
const ctx = await createBaseContext({
@@ -1583,7 +1589,9 @@ describe("processDiscordMessage session routing", () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.();
await new Promise((resolve) => setTimeout(resolve, 1_000));
await new Promise((resolve) => {
setTimeout(resolve, 1_000);
});
return createNoQueuedDispatchResult();
});
const ctx = await createBaseContext({

View File

@@ -119,7 +119,9 @@ export async function applyDiscordModelPickerSelection(params: {
const fallbackRoute = dispatchResult.effectiveRoute ?? params.route;
if (params.settleMs > 0) {
await new Promise((resolve) => setTimeout(resolve, params.settleMs));
await new Promise((resolve) => {
setTimeout(resolve, params.settleMs);
});
}
let effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
@@ -135,7 +137,9 @@ export async function applyDiscordModelPickerSelection(params: {
params.selectedModel === params.defaultModel,
runtime: params.selectedRuntime,
});
await new Promise((resolve) => setTimeout(resolve, 100));
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
persisted = effectiveModelRef === params.resolvedModelRef;
}
@@ -155,7 +159,9 @@ export async function applyDiscordModelPickerSelection(params: {
params.selectedModel === params.defaultModel,
runtime: params.selectedRuntime,
});
await new Promise((resolve) => setTimeout(resolve, 100));
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
persisted = effectiveModelRef === params.resolvedModelRef;
if (!persisted) {

View File

@@ -336,7 +336,7 @@ export function formatDiscordDeployErrorDetails(err: unknown): string {
details.push(`code=${discordCode}`);
}
if (rawBody !== undefined) {
let bodyText = "";
let bodyText;
try {
bodyText = JSON.stringify(rawBody);
} catch {

View File

@@ -440,7 +440,9 @@ describe("createDiscordGatewayPlugin", () => {
process.on("unhandledRejection", onUnhandledRejection);
try {
startIgnoredGatewayRegistration(plugin);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(unhandledReasons).toHaveLength(0);
const registration = waitForDiscordGatewayPluginRegistration(plugin);

View File

@@ -179,52 +179,52 @@ class DiscordOpusEncodeStream extends Transform {
return this.#encoder;
}
override async _transform(
chunk: Buffer,
_encoding: BufferEncoding,
done: TransformCallback,
): Promise<void> {
try {
const encoder = await this.#getEncoder();
this.#buffer =
this.#buffer.length > 0 ? Buffer.concat([this.#buffer, chunk]) : Buffer.from(chunk);
while (this.#buffer.length >= DISCORD_OPUS_FRAME_BYTES) {
const frame = this.#buffer.subarray(0, DISCORD_OPUS_FRAME_BYTES);
this.#buffer = this.#buffer.subarray(DISCORD_OPUS_FRAME_BYTES);
this.push(
Buffer.from(
encoder.encode(frame, {
frameSize: DISCORD_OPUS_FRAME_SIZE,
}),
),
);
override _transform(chunk: Buffer, _encoding: BufferEncoding, done: TransformCallback): void {
void (async () => {
try {
const encoder = await this.#getEncoder();
this.#buffer =
this.#buffer.length > 0 ? Buffer.concat([this.#buffer, chunk]) : Buffer.from(chunk);
while (this.#buffer.length >= DISCORD_OPUS_FRAME_BYTES) {
const frame = this.#buffer.subarray(0, DISCORD_OPUS_FRAME_BYTES);
this.#buffer = this.#buffer.subarray(DISCORD_OPUS_FRAME_BYTES);
this.push(
Buffer.from(
encoder.encode(frame, {
frameSize: DISCORD_OPUS_FRAME_SIZE,
}),
),
);
}
done();
} catch (err) {
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
}
done();
} catch (err) {
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
}
})();
}
override async _final(done: TransformCallback): Promise<void> {
try {
if (this.#buffer.length > 0) {
const encoder = await this.#getEncoder();
const frame = Buffer.alloc(DISCORD_OPUS_FRAME_BYTES);
this.#buffer.copy(frame);
this.#buffer = Buffer.alloc(0);
this.push(
Buffer.from(
encoder.encode(frame, {
frameSize: DISCORD_OPUS_FRAME_SIZE,
}),
),
);
override _final(done: TransformCallback): void {
void (async () => {
try {
if (this.#buffer.length > 0) {
const encoder = await this.#getEncoder();
const frame = Buffer.alloc(DISCORD_OPUS_FRAME_BYTES);
this.#buffer.copy(frame);
this.#buffer = Buffer.alloc(0);
this.push(
Buffer.from(
encoder.encode(frame, {
frameSize: DISCORD_OPUS_FRAME_SIZE,
}),
),
);
}
this.#freeEncoder();
done();
} catch (err) {
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
}
this.#freeEncoder();
done();
} catch (err) {
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
}
})();
}
override _destroy(err: Error | null, done: (error?: Error | null) => void): void {

View File

@@ -3865,14 +3865,18 @@ describe("DiscordVoiceManager", () => {
resolveSecond?.({ payloads: [{ text: "second answer" }] });
resolveThird?.({ payloads: [{ text: "third answer" }] });
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expectUserMessageNotIncludes("second answer");
expectUserMessageNotIncludes("third answer");
bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
const firstStream = lastAudioResourceInput() as PassThrough | undefined;
await vi.waitFor(() => expect(firstStream?.writableEnded).toBe(true));
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expectUserMessageNotIncludes("second answer");
const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as
@@ -3886,7 +3890,9 @@ describe("DiscordVoiceManager", () => {
bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
const secondStream = lastAudioResourceInput() as PassThrough | undefined;
await vi.waitFor(() => expect(secondStream?.writableEnded).toBe(true));
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expectUserMessageNotIncludes("third answer");
idleHandler?.();
@@ -3950,7 +3956,9 @@ describe("DiscordVoiceManager", () => {
bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
const firstStream = lastAudioResourceInput() as PassThrough | undefined;
await vi.waitFor(() => expect(firstStream?.writableEnded).toBe(true));
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expectUserMessageNotIncludes("second answer");
const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as

View File

@@ -790,34 +790,36 @@ export class DiscordVoiceManager {
this.scheduleCaptureFinalize(entry, userId, "speaker end");
};
const disconnectedHandler: (() => Promise<void>) | undefined = async () => {
try {
logVoiceVerbose(
`disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`,
);
await Promise.race([
voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Signalling,
reconnectGraceMs,
),
voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Connecting,
reconnectGraceMs,
),
]);
logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`);
} catch (err) {
logger.warn(
`discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
);
clearSessionIfCurrent();
stopEntry(entry, {
destroyConnection: true,
reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
});
}
const disconnectedHandler: (() => void) | undefined = () => {
void (async () => {
try {
logVoiceVerbose(
`disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`,
);
await Promise.race([
voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Signalling,
reconnectGraceMs,
),
voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Connecting,
reconnectGraceMs,
),
]);
logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`);
} catch (err) {
logger.warn(
`discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
);
clearSessionIfCurrent();
stopEntry(entry, {
destroyConnection: true,
reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
});
}
})();
};
const destroyedHandler: (() => void) | undefined = () => {
clearSessionIfCurrent();

View File

@@ -538,7 +538,9 @@ async function waitForFalQueueResult(params: {
throw new Error(FAL_VIDEO_MALFORMED_RESPONSE);
}
const pollDelayMs = resolveFalQueueRemainingMs(params.deadline, lastStatus, POLL_INTERVAL_MS);
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
await new Promise((resolve) => {
setTimeout(resolve, pollDelayMs);
});
}
}

View File

@@ -18,7 +18,11 @@ export type {
ReplyPayload,
} from "openclaw/plugin-sdk/core";
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type RuntimeEnv = {
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
exit: (code: number) => void;
};
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-contracts";
export {
DEFAULT_ACCOUNT_ID,

View File

@@ -340,7 +340,9 @@ export async function getAppOwnerOpenId(params: {
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function sleepRegistrationPollInterval(intervalSeconds: number): Promise<void> {

View File

@@ -92,7 +92,7 @@ export function resolveFeishuGroupSession(params: {
(replyInThread ? messageId : null))
: null;
let peerId = chatId;
let peerId;
switch (groupSessionScope) {
case "group_sender":
peerId = buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });

View File

@@ -19,6 +19,7 @@ const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgen
},
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback: vi.fn(),
})),
mockCreateFeishuClient: vi.fn(),
mockResolveAgentRoute: vi.fn(),
@@ -227,6 +228,20 @@ describe("broadcast dispatch", () => {
lastRoutePolicy: "session",
matchedBy: "default",
});
mockCreateFeishuReplyDispatcher.mockReturnValue({
dispatcher: {
sendToolResult: vi.fn(),
sendBlockReply: vi.fn(),
sendFinalReply: vi.fn(),
waitForIdle: vi.fn(),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback: vi.fn(),
});
mockCreateFeishuClient.mockReturnValue({
contact: {
user: {
@@ -329,6 +344,130 @@ describe("broadcast dispatch", () => {
expect(dispatcherParams?.agentId).toBe("main");
});
it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
mockDispatchReplyFromConfig
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({
queuedFinal: false,
counts: { final: 0 },
noVisibleReplyFallbackEligible: true,
});
const ensureNoVisibleReplyFallback = vi.fn();
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
dispatcher: {
sendToolResult: vi.fn(),
sendBlockReply: vi.fn(),
sendFinalReply: vi.fn(),
waitForIdle: vi.fn(),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback,
});
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-zero-final",
text: "hello @bot",
botMentioned: true,
});
await handleFeishuMessage({
cfg,
event,
botOpenId: "bot-open-id",
runtime: createRuntimeEnv(),
});
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith(
"broadcast-dispatch-complete-no-visible-reply",
);
});
it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
mockDispatchReplyFromConfig
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({
queuedFinal: true,
counts: { final: 1 },
});
const ensureNoVisibleReplyFallback = vi.fn();
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
dispatcher: {
sendToolResult: vi.fn(),
sendBlockReply: vi.fn(),
sendFinalReply: vi.fn(),
waitForIdle: vi.fn(),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 1 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback,
});
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-final-failed",
text: "hello @bot",
botMentioned: true,
});
await handleFeishuMessage({
cfg,
event,
botOpenId: "bot-open-id",
runtime: createRuntimeEnv(),
});
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith(
"broadcast-dispatch-complete-no-visible-reply",
);
});
it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
mockDispatchReplyFromConfig
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({
queuedFinal: false,
counts: { final: 0 },
sourceReplyDeliveryMode: "message_tool_only",
noVisibleReplyFallbackEligible: true,
});
const ensureNoVisibleReplyFallback = vi.fn();
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
dispatcher: {
sendToolResult: vi.fn(),
sendBlockReply: vi.fn(),
sendFinalReply: vi.fn(),
waitForIdle: vi.fn(),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback,
});
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-source-suppressed",
text: "hello @bot",
botMentioned: true,
});
await handleFeishuMessage({
cfg,
event,
botOpenId: "bot-open-id",
runtime: createRuntimeEnv(),
});
expect(ensureNoVisibleReplyFallback).not.toHaveBeenCalled();
});
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({

View File

@@ -210,6 +210,7 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
onRecordError: turn.record?.onRecordError ?? (() => undefined),
});
return {
dispatched: true,
dispatchResult: await turn.runDispatch(),
};
}),
@@ -294,6 +295,7 @@ const {
dispatcher: createReplyDispatcher(),
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback: vi.fn(),
})),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
@@ -464,6 +466,7 @@ describe("handleFeishuMessage ACP routing", () => {
dispatcher: createReplyDispatcher(),
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback: vi.fn(),
});
setFeishuRuntime(createFeishuBotRuntime());
@@ -1046,6 +1049,90 @@ describe("handleFeishuMessage command authorization", () => {
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
it("does not send no-visible fallback when send policy denied delivery", async () => {
mockDispatchReplyFromConfig.mockResolvedValueOnce({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
sendPolicyDenied: true,
noVisibleReplyFallbackEligible: true,
});
const ensureNoVisibleReplyFallback = vi.fn();
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
dispatcher: createReplyDispatcher(),
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback,
});
await dispatchMessage({
cfg: {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig,
event: {
sender: {
sender_id: {
open_id: "ou-sender",
},
},
message: {
message_id: "msg-send-policy-deny",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(ensureNoVisibleReplyFallback).not.toHaveBeenCalled();
});
it("sends no-visible fallback when queued final delivery fails", async () => {
mockDispatchReplyFromConfig.mockResolvedValueOnce({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
});
const ensureNoVisibleReplyFallback = vi.fn();
const dispatcher = createReplyDispatcher();
vi.mocked(dispatcher.getFailedCounts).mockReturnValue({ tool: 0, block: 0, final: 1 });
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
dispatcher,
replyOptions: {},
markDispatchIdle: vi.fn(),
ensureNoVisibleReplyFallback,
});
await dispatchMessage({
cfg: {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig,
event: {
sender: {
sender_id: {
open_id: "ou-sender",
},
},
message: {
message_id: "msg-final-delivery-failed",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith("dispatch-complete-no-visible-reply");
});
it("passes disabled config-write policy to dynamic agent creation", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@@ -88,6 +88,28 @@ const GROUP_NAME_CACHE_MAX_SIZE = 500; // hard cap
type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
function shouldSendNoVisibleReplyFallback(dispatchResult: {
counts: { final?: number };
failedCounts?: { final?: number };
noVisibleReplyFallbackEligible?: boolean;
queuedFinal?: boolean;
sendPolicyDenied?: boolean;
sourceReplyDeliveryMode?: string;
}): boolean {
const finalCount = dispatchResult.counts.final ?? 0;
const failedFinalCount = dispatchResult.failedCounts?.final ?? 0;
const emptyEligibleDispatch =
dispatchResult.noVisibleReplyFallbackEligible === true &&
dispatchResult.queuedFinal !== true &&
finalCount === 0;
const queuedFinalFailed = dispatchResult.queuedFinal === true && failedFinalCount > 0;
return (
dispatchResult.sendPolicyDenied !== true &&
dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" &&
(emptyEligibleDispatch || queuedFinalFailed)
);
}
function resolveConfiguredFeishuGroupSessionScope(params: {
groupConfig?: {
groupSessionScope?: FeishuGroupSessionScope;
@@ -1487,26 +1509,28 @@ export async function handleFeishuMessage(params: {
if (agentId === activeAgentId) {
// Active agent: real Feishu dispatcher (responds on Feishu)
const identity = resolveAgentOutboundIdentity(cfg, agentId);
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply,
accountId: account.accountId,
identity,
messageCreateTimeMs,
});
const { dispatcher, replyOptions, markDispatchIdle, ensureNoVisibleReplyFallback } =
createFeishuReplyDispatcher({
cfg,
agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply,
accountId: account.accountId,
identity,
messageCreateTimeMs,
sessionKey: agentSessionKey,
});
log(
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
);
await core.channel.inbound.run({
const turnResult = await core.channel.inbound.run({
channel: "feishu",
accountId: route.accountId,
raw: ctx,
@@ -1547,6 +1571,15 @@ export async function handleFeishuMessage(params: {
}),
},
});
if (
turnResult.dispatched &&
shouldSendNoVisibleReplyFallback({
...turnResult.dispatchResult,
failedCounts: dispatcher.getFailedCounts(),
})
) {
await ensureNoVisibleReplyFallback("broadcast-dispatch-complete-no-visible-reply");
}
} else {
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
// Strip CommandAuthorized so slash commands (e.g. /reset) don't silently
@@ -1652,21 +1685,23 @@ export async function handleFeishuMessage(params: {
storePath,
sessionKey: route.sessionKey,
});
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply,
accountId: account.accountId,
identity,
messageCreateTimeMs,
});
const { dispatcher, replyOptions, markDispatchIdle, ensureNoVisibleReplyFallback } =
createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply,
accountId: account.accountId,
identity,
messageCreateTimeMs,
sessionKey: route.sessionKey,
});
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const turnResult = await core.channel.inbound.run({
@@ -1733,6 +1768,14 @@ export async function handleFeishuMessage(params: {
}
const { dispatchResult } = turnResult;
const { queuedFinal, counts } = dispatchResult;
if (
shouldSendNoVisibleReplyFallback({
...dispatchResult,
failedCounts: dispatcher.getFailedCounts(),
})
) {
await ensureNoVisibleReplyFallback("dispatch-complete-no-visible-reply");
}
log(
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,

View File

@@ -64,7 +64,7 @@ export const detectFeishuLegacyStateMigrations: BundledChannelLegacyStateMigrati
stateDir,
}) => {
const dedupDir = path.join(stateDir, "feishu", "dedup");
let entries: fs.Dirent[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dedupDir, { withFileTypes: true });
} catch {

View File

@@ -400,7 +400,7 @@ function inspectSessionTranscript(params: {
return null;
}
let raw = "";
let raw;
try {
raw = fs.readFileSync(params.transcriptPath, "utf-8");
} catch {

View File

@@ -19,11 +19,13 @@ type DispatchReplyContext = Record<string, unknown> & {
};
type DispatchReplyDispatcher = {
sendFinalReply: (payload: { text: string }) => unknown;
getFailedCounts?: UnknownMock;
};
type FeishuReplyDispatcherMockValue = {
dispatcher: DispatchReplyDispatcher;
replyOptions: Record<string, never>;
markDispatchIdle: () => unknown;
ensureNoVisibleReplyFallback?: AsyncUnknownMock;
};
type CreateFeishuReplyDispatcherMock = Mock<(params?: unknown) => FeishuReplyDispatcherMockValue>;
type DispatchReplyFromConfigMock = Mock<

View File

@@ -1,4 +1,4 @@
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { RuntimeEnv } from "../runtime-api.js";
export {
createFixedWindowRateLimiter,
createWebhookAnomalyTracker,

View File

@@ -474,7 +474,7 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
}
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null = null;
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null | undefined;
try {
const eventDispatcher = createEventDispatcher(account);
const chatHistories = new Map<string, HistoryEntry[]>();

View File

@@ -361,7 +361,9 @@ async function resolveParsedCommentContent(params: {
}
async function delayMs(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function buildDriveCommentTargetUrl(params: {

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