Compare commits

..

121 Commits

Author SHA1 Message Date
Vincent Koc
2f02bbcbb3 fix: harden legacy session SQLite migration 2026-06-09 18:44:42 +09:00
openclaw-clownfish[bot]
5e1fbca3cb docs: clarify Android opt-in for release CI (#91665)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 18:20:39 +09:00
openclaw-clownfish[bot]
e949809f6e chore(plugin-sdk): refresh API baseline hash (#91661)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 18:20:35 +09:00
Vincent Koc
25160515e0 test(runner): skip deleted changed test targets 2026-06-09 17:34:23 +09:00
Vincent Koc
af5c7c5fd0 test(agents): drop duplicate prompt template wrapper coverage 2026-06-09 17:34:23 +09:00
Vincent Koc
cd0bca0823 test(qqbot): reduce group allways command scaffolding 2026-06-09 17:34:23 +09:00
Vincent Koc
0a1cf8a776 test(vitest): prune duplicate include wrapper coverage 2026-06-09 17:34:22 +09:00
Vincent Koc
446936d600 test(ui): prune duplicate presenter coverage 2026-06-09 17:34:22 +09:00
Mason Huang
257b251e26 fix(docs): continue partial i18n batches after file errors (#91642)
Summary:
- This PR passes the existing docs-i18n `--allow-partial` flag into sequential and parallel doc-mode schedulin ... ion as terminal, adds regression tests, and removes one non-null assertion in Microsoft Foundry onboarding.
- PR surface: Source 0, Other +286. Total +286 across 3 files.
- Reproducibility: yes. at source level: current main returns from sequential doc mode on the first `processFi ... d not run Go tests because this review is read-only, but the PR adds direct regression cases for that path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(docs): continue partial i18n batches after file errors
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-9164…

Validation:
- ClawSweeper review passed for head b66c0983b4.
- Required merge gates passed before the squash merge.

Prepared head SHA: b66c0983b4
Review: https://github.com/openclaw/openclaw/pull/91642#issuecomment-4656851389

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-09 08:10:54 +00:00
Vincent Koc
c517162536 fix(azure): narrow Foundry Responses client detection 2026-06-09 16:45:14 +09:00
Vincent Koc
1240de7588 fix(microsoft-foundry): filter unsupported Anthropic deployments
Co-authored-by: Otto Deng <ottodeng@users.noreply.github.com>
2026-06-09 16:45:14 +09:00
Vincent Koc
93d540d67b test(azure): keep reasoning replay assertions focused
Co-authored-by: thomas.krohnfuss <thomas.krohnfuss@hsu.hamburg>
2026-06-09 16:45:14 +09:00
Vincent Koc
1727ec7b2d fix(azure): use OpenAI-compatible client for Foundry Responses
Co-authored-by: thomas.krohnfuss <thomas.krohnfuss@hsu.hamburg>
2026-06-09 16:45:14 +09:00
Vincent Koc
b08e1109c6 fix(azure): support Responses text stream events
Co-authored-by: thomas.krohnfuss <thomas.krohnfuss@hsu.hamburg>
2026-06-09 16:45:13 +09:00
cxy
d12b7b0551 feat(qqbot): add /bot-group-allways command to toggle mention requirement (#91423)
* feat(qqbot): add /bot-group-allways command to toggle group mention requirement

Add slash command to configure defaultRequireMention for qqbot accounts.
Clear runtime config snapshot cache after config write to ensure
getRuntimeConfig() reads fresh values on next message.

- Add register-group-allways command (on/off/status)
- Support named accounts and default account
- Clear runtime config cache after write for immediate effect
- Add unit tests for group config resolution

* test(qqbot): fix group allways test imports

* feat(qqbot): add /bot-group-allways command to toggle group mention requirement (#91423) (thanks @cxyhhhhh)

---------

Co-authored-by: sliverp <870080352@qq.com>
2026-06-09 15:43:12 +08:00
openclaw-clownfish[bot]
994f4f99fe fix(line): canonicalize trailing-slash webhook paths (#91649)
* fix(line): canonicalize trailing-slash webhook paths

* fix(clownfish): address review for clawsweeper-commit-openclaw-openclaw-4cf228466770 (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 16:26:16 +09:00
Vincent Koc
f57c3b55fd fix(microsoft-foundry): repair CI validation issues 2026-06-09 15:45:19 +09:00
openclaw-clownfish[bot]
56fe1e0c95 docs: include plugin prerelease in release validation approval (#91637)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 15:42:13 +09:00
Vincent Koc
7128fa8832 fix(image-generation): preserve explicit model defaults 2026-06-09 15:34:29 +09:00
Vincent Koc
e103d1231d fix(microsoft-foundry): classify manual MAI image setup 2026-06-09 15:34:29 +09:00
Vincent Koc
a1fb8cf304 test(image-generation): load live provider sources 2026-06-09 15:34:29 +09:00
Vincent Koc
ea51c3ea50 fix(microsoft-foundry): allow MAI image deployment prefixes 2026-06-09 15:34:28 +09:00
Vincent Koc
34e76e6e6f test(image-generation): include Foundry in live image sweep 2026-06-09 15:34:28 +09:00
Vincent Koc
090c492759 fix(microsoft-foundry): configure MAI image setup defaults 2026-06-09 15:34:28 +09:00
Vincent Koc
09a5cdaca3 fix(microsoft-foundry): expose auth for image setup 2026-06-09 15:34:28 +09:00
Vincent Koc
c93e837336 fix(microsoft-foundry): require deployment refs for MAI images 2026-06-09 15:34:28 +09:00
Vincent Koc
9cf46d7e5a fix(microsoft-foundry): trust only distinct MAI model metadata 2026-06-09 15:34:28 +09:00
Vincent Koc
5e4a160f54 fix(image-generation): allow explicit defaultless model refs 2026-06-09 15:34:28 +09:00
Vincent Koc
33cac9092b fix(microsoft-foundry): honor MAI image timeout deadlines 2026-06-09 15:34:28 +09:00
Vincent Koc
0c60bad890 fix(microsoft-foundry): require MAI image deployment defaults 2026-06-09 15:34:28 +09:00
Vincent Koc
a172db54b4 fix(microsoft-foundry): allow MAI deployment ids for image generation 2026-06-09 15:34:28 +09:00
Vincent Koc
5f13d0c817 docs(microsoft-foundry): document MAI image support 2026-06-09 15:34:28 +09:00
Vincent Koc
d0a84089a0 feat(microsoft-foundry): add MAI image provider 2026-06-09 15:34:28 +09:00
Vincent Koc
1ba782f286 feat(microsoft-foundry): classify MAI model metadata 2026-06-09 15:34:28 +09:00
Onur Solmaz
3137110167 fix(memory): move local llama.cpp runtime to provider plugin
* fix(memory): move local llama.cpp runtime to provider plugin

* chore: ignore llama cpp dynamic dependency

* test: remove invalid local provider alias fixture

* chore: refresh llama cpp shrinkwrap

* chore: drop stale memory embedding defaults facade
2026-06-09 14:30:35 +08:00
Vincent Koc
4c98a547d0 docs: redirect retired app sdk pages 2026-06-09 14:57:50 +09:00
Vincent Koc
634bcf6667 docs: clarify external app integration path 2026-06-09 14:56:17 +09:00
Josh Avant
e1978cf73c fix main session startup recovery (#91566) 2026-06-09 00:37:16 -05:00
colmbrogan
7e3100a120 fix(imessage): persist echo markers before send (#88969)
Persist short-lived pending iMessage echo markers before bridge sends so self-chat reflected rows cannot race ahead of post-send echo persistence. Keep monitor cache writes post-send, keep pending text out of generic echo matching, and observe skipped from-me catchup rows for self-chat dedupe.\n\nThanks @colmbrogan.
2026-06-08 22:25:25 -07:00
Pavan Kumar Gondhi
03a8d18cd4 fix(memory-lancedb): guard memory recall output [AI] (#91425)
* fix: guard memory recall output

* fix: overfetch memory recall candidates

* fix: avoid memory recall lint shadow
2026-06-09 10:31:55 +05:30
Vincent Koc
6fcc945702 fix(agents): trim dense text delta snapshots
Trim dense plain text-delta stream snapshots for OpenAI-compatible, Responses, and Ollama providers while preserving full snapshots on stream checkpoints and terminal events.

Reconstruct partial-less text deltas in the agent loop so live message updates continue to advance for immutable snapshot providers, and document the optional text_delta.partial contract.

Fixes #86599.
2026-06-09 13:21:23 +09:00
Vincent Koc
b3c946999d perf(control-ui): lazy load slash commands
Avoid clean Control UI startup command discovery, then hydrate slash commands only on explicit slash or palette intent. Proof: local focused unit tests, mocked Gateway E2E, Testbox check:changed, autoreview clean, and GitHub CI clean.
2026-06-09 13:09:50 +09:00
Patrick Erichsen
f05e9873c6 fix: let clawhub dry runs skip publish approval (#91591) 2026-06-08 21:04:32 -07:00
Josh Avant
9fdd56da21 fix(openai): require api-key auth for realtime voice (#91567)
* fix(openai): require api-key auth for realtime voice

* test(plugin-sdk): avoid auth profile store shadowing
2026-06-08 22:55:06 -05:00
BCM
4c55dd8549 fix(ui): guard WebRTC Talk startup cancellation
Stop stale async WebRTC setup after a user cancellation so a late microphone grant does not dereference a nulled peer or continue SDP setup.

Fixes #89434
2026-06-09 12:46:31 +09:00
Mason Huang
162957565a fix: make docs i18n frontmatter translation resilient (#91578)
Summary:
- The PR updates the docs i18n Go translator to bypass exact glossary matches, recover non-empty Codex last-me ... r a non-zero exit, fall back to source frontmatter scalars on translation errors, and add regression tests.
- PR surface: Other +201. Total +201 across 4 files.
- Reproducibility: yes. Current main source shows the relevant paths: exact glossary scalars still go through  ... re the last-message file; the PR body also describes a real translator smoke run that hit the failure mode.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix: make docs i18n frontmatter translation resilient

Validation:
- ClawSweeper review passed for head efd98bba14.
- Required merge gates passed before the squash merge.

Prepared head SHA: efd98bba14
Review: https://github.com/openclaw/openclaw/pull/91578#issuecomment-4655639231

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-09 03:43:01 +00:00
Sally O'Malley
c8a8152cd7 fix docker store seed target packages (#91547) 2026-06-08 23:38:46 -04:00
T-800
84acb74a6a fix(feishu): retry on send rate-limit errors (230020/230006) (#89659)
* fix(feishu): add retry with linear backoff for send rate-limit errors

When Feishu returns code 230020 (per-chat rate limit), requestFeishuApi
now retries up to 2 times with linear backoff (500ms, 1000ms). The reply
path (im.message.reply) is also covered via the same retry helper.

Confirmed by a real 20-concurrent-send stress test: all 20 messages
succeed after retry.

Closes #70879

* ci: retrigger CI

* fix(feishu): retry HTTP 429 and code 11232 for message send rate limits

Feishu Open API has three send-time rate limit signals: HTTP 429
(gateway-wide quota), business code 11232 (tenant-level message
service: 100/min, 5/sec), and 230020 (per-chat). Previously only
230020 was retried; HTTP 429 and 11232 propagated as fatal errors.

- Add 11232 to FEISHU_SEND_RATE_LIMIT_CODES.
- In getFeishuSendRateLimitCode, recognize HTTP 429 before reading
  the body code so gateway-level limits enter the retry loop.
- Update doc comment listing both gateway and business sources.

* test(feishu): add focused retry coverage for 11232 and HTTP 429

The previous send.retry.test.ts only exercised 230020 / 230006 / non-rate
codes / plain errors. After expanding the retry policy in 90c787096 to
cover code 11232 (tenant-level message rate limit) and gateway-level
HTTP 429, ClawSweeper review #89659 (P2) flagged the tests as no longer
matching the production behavior.

- getFeishuSendRateLimitCode: assert 11232 returns 11232, HTTP 429
  returns 429, and HTTP 429 wins over body code when both are present.
- requestFeishuApi: cover 11232 retry-then-success, 429 retry-then-success,
  exhaustion paths for both, and a mixed 230020 → 11232 → ok recovery.

* fix(feishu): retry on fulfilled rate-limit response bodies (no-throw)

The Feishu node SDK sometimes resolves a non-throwing response that
carries a rate-limit code in its body (e.g. { code: 11232, msg: ... })
instead of rejecting. requestFeishuApi previously returned that body
straight away and downstream assertFeishuMessageApiSuccess failed once
with no retry — the same shape that issue #28157 fixed earlier on the
typing/reaction path via getBackoffCodeFromResponse.

ClawSweeper review on #89659 (P1, comment-shared.ts:140) flagged the
gap. Mirror the typing-path pattern for the send helper:

- Add getFeishuSendRateLimitCodeFromResponse to classify fulfilled
  bodies against FEISHU_SEND_RATE_LIMIT_CODES (230020, 11232).
- In requestFeishuApi, after each fulfilled await, classify before
  returning. If the body is a retryable rate limit and there are
  attempts left, continue the loop. After exhaustion, wrap the last
  fulfilled body into a synthetic AxiosError-shaped error so callers
  see the same error shape as the throw path.
- Add 11 focused tests covering fulfilled 11232/230020 retry-then-ok,
  exhaustion, mixed throw → fulfilled → ok recovery, and pass-through
  for code 0 / non-rate-limit codes.

* fix(feishu): break loop on final-attempt fulfilled rate-limit body

ClawSweeper review on dc8d3be7d (P1, comment-shared.ts:166) caught a
real bug: when the final retry attempt also fulfilled with a rate-limit
body (e.g. { code: 11232, ... }), the guard `attempt < FEISHU_SEND_MAX_RETRIES`
was false so control fell through to `return result` — bypassing the
synthetic-error exhaustion path and handing the rate-limit body to the
caller as if it were a successful response. The fulfilled-exhaustion
test missed this because Vitest's local fs module cache served the
pre-fix shape; running with a fresh cache reproduces the failure.

Split the fulfilled-rate-limit branch so the body is always captured,
then continue on a non-final attempt or break on the final attempt.
Breaking falls through to the synthetic AxiosError-shaped throw below,
which is exactly what the existing exhaustion test asserts.

* fix(feishu): retry on send rate-limit errors 230020/11232/429 (#89659) (thanks @ladygege)

---------

Co-authored-by: marshall.m <marshall.m@binance.com>
Co-authored-by: sliverp <870080352@qq.com>
2026-06-09 11:34:21 +08:00
Vincent Koc
9bbde70458 fix(skills): avoid per-file skill watchers 2026-06-09 12:27:53 +09:00
Vincent Koc
099abea089 perf(control-ui): warn on slow first replies (#91583) 2026-06-09 12:26:00 +09:00
Patrick Erichsen
e8cf6df3a3 feat: dogfood reusable ClawHub package publish 2026-06-08 20:19:21 -07:00
Youssef Hemimy
9210d8f7d9 fix(whatsapp): route captured replies through successor controller after restart (#85823)
Merged via squash.

Prepared head SHA: 5df8c79654
Co-authored-by: itsuzef <53057646+itsuzef@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-06-09 00:05:44 -03:00
Vincent Koc
c9050c982d perf(control-ui): trace first assistant event
Add Control UI first-assistant chat send timing instrumentation and tests.
2026-06-09 12:03:30 +09:00
Vincent Koc
0933726574 fix(sdk): surface event pump failures 2026-06-09 11:50:53 +09:00
Josh Avant
9f48254f09 Fix config.patch explicit array replacement (#91551)
* fix config patch explicit array replacement

* fix generated config patch protocol model

* fix config patch test helper typing

* fix shared auth patch replacement tests

* update config patch prompt snapshots

* harden qa lab config patch replace paths
2026-06-08 21:48:46 -05:00
Vincent Koc
329fa44d23 fix(memory-core): write deep sleep summaries to dreams 2026-06-09 11:31:31 +09:00
Josh Avant
aef1fad58d Fix transcript image redaction (#91529)
* fix transcript image redaction

* fix image redaction type predicate

* tighten transcript image redaction boundary
2026-06-08 21:23:15 -05:00
harjoth
82afc4678a fix(config): use Start-Process -FilePath for Windows config opener (#90157) 2026-06-09 11:16:15 +09:00
Ayaan Zaidi
aa935ddeb2 fix(telegram): keep compact command replies visible 2026-06-09 07:43:01 +05:30
Ayaan Zaidi
fff5261ade fix(telegram): keep compact acknowledgements in dispatcher 2026-06-09 07:43:01 +05:30
joelnishanth
5ef0d6c693 fix: resolve CI lint/type/deadcode failures
- Remove unused import normalizeOptionalLowercaseString from commands-compact.ts
- Remove unused type import ReplyPayload from bot-native-commands.ts
- Replace spread-in-map with Object.assign to satisfy oxlint
- Delete orphaned native-command-ack-fallback.ts and its test (superseded by direct delivery)
- Add isStatusNotice to ReplyPayloadLike test type
- Update test to verify status notices bypass dispatch pipeline
2026-06-09 07:43:01 +05:30
joelnishanth
38a11944f4 fix(telegram): deliver native /compact ack directly, bypassing dispatch pipeline
The dispatch pipeline (foreground fence, operation-busy checks, hooks)
silently dropped status-notice replies for /compact. Resolve the command
reply directly via getReplyFromConfig and deliver status notices through
deliverReplies without entering the full dispatch pipeline. Non-status
commands still use the buffered dispatch for streaming/tools.

Adds deliverDespiteSourceReplySuppression metadata for command replies,
a dedicated native-command-ack-fallback module, and regression tests.

Fixes #89525

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 07:43:01 +05:30
joelnishanth
1559c16a76 fix(telegram): classify generic control commands as text slash
Generic message ingress lacked commandSource, so /compact and other
authorized control commands were dropped when they missed bot.command.
Derive text-slash command metadata like Mattermost/iMessage.

Fixes #89525
2026-06-09 07:43:01 +05:30
Vincent Koc
280d1cb977 fix(auto-reply): deliver queued compaction notices 2026-06-09 11:07:37 +09:00
Josh Avant
14b1ebd640 fix: bound native hook relay lifetime (#91550) 2026-06-08 21:06:58 -05:00
Dallin Romney
5097749de3 fix: canonicalize codex protocol JSON assets (#91507) 2026-06-08 18:59:51 -07:00
Marcus Castro
27189b3e74 test(whatsapp): seed group activation store via facade 2026-06-08 22:58:04 -03:00
Vincent Koc
79c6136a9e perf(control-ui): avoid startup catalog wait
Start the optional model catalog load early for chat.startup and cap startup-only catalog waiting at 25ms, while preserving the 750ms optional catalog wait for other gateway surfaces. Adds regressions for slow catalog omission, async cached metadata, and agent-scoped startup metadata.
2026-06-09 10:35:39 +09:00
Vincent Koc
c4a0ca0b7a perf(agents): cache subagent registry reads 2026-06-09 10:16:15 +09:00
Vincent Koc
dfb44912ed fix(acpx): normalize Claude ACP model refs 2026-06-09 10:01:57 +09:00
Vincent Koc
80f1ae6ffe fix(infra): fail fast for sync sqlite facade execution 2026-06-09 09:55:20 +09:00
Vincent Koc
2c6bdc8b28 perf(control-ui): reuse startup model metadata (#91531) 2026-06-09 09:44:32 +09:00
brokemac79
72e40833ba fix(doctor): report managed plugin version drift
Fixes #90891.

Doctor now reports official managed plugin version drift from the daemon-local status path, using the probed running gateway version and suppressing the advisory when probe auth is skipped or unsafe. The status probe also avoids re-entering config-backed exec SecretRef credential resolution when exec refs are disabled.

Verification:
- `node scripts/run-vitest.mjs src/commands/agent-via-gateway.test.ts src/cli/daemon-cli/probe.test.ts src/cli/daemon-cli/status.gather.test.ts src/flows/doctor-health-contributions.test.ts src/commands/doctor-workspace-status.test.ts src/gateway/probe-auth.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Crabbox delegated Blacksmith Testbox `tbx_01ktmwa5q0c2eb688dkbkw8v2b`: `OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed`
2026-06-09 09:44:01 +09:00
Shakker
5b76436c45 test: satisfy cron cancellation lint 2026-06-09 01:22:29 +01:00
Shakker
9082233a43 fix: unblock timed cron cancellation 2026-06-09 01:22:29 +01:00
Shakker
24196e05f5 fix: unwind timeout-disabled cron cancellation 2026-06-09 01:22:29 +01:00
Shakker
93313c95a5 fix: preserve cron timeout terminal state 2026-06-09 01:22:29 +01:00
Shakker
be5bfdccd1 test: remove stale cron cancel harness hook 2026-06-09 01:22:29 +01:00
Shakker
372f85d368 fix: avoid cron cancel runtime cycle 2026-06-09 01:22:29 +01:00
ai-hpc
3cf94309d9 fix(cron): keep main-session cron cancel honest 2026-06-09 01:22:29 +01:00
ai-hpc
f45cd5e57e test(cron): type unresolved runner mock 2026-06-09 01:22:29 +01:00
ai-hpc
c13802c912 fix(cron): preserve timeout cleanup after cancel 2026-06-09 01:22:29 +01:00
ai-hpc
c3cdd4971b fix(cron): cancel active cron task runs 2026-06-09 01:22:29 +01:00
Vincent Koc
5f6ee9f913 fix(release): prepare ClawHub publish deps after target checkout 2026-06-09 08:48:07 +09:00
Vincent Koc
ebb9c6a013 test(release): dedupe gateway migration mock 2026-06-09 01:02:24 +02:00
Vincent Koc
0df7fe3056 chore(release): keep main changelog release-owned 2026-06-09 01:02:24 +02:00
Vincent Koc
50130d32a9 test(release): align qa tool coverage gate 2026-06-09 01:02:24 +02:00
Vincent Koc
7a0e65773a test(release): ignore terminal docker stats samples 2026-06-09 01:02:24 +02:00
Vincent Koc
c7b01cf201 test(release): stabilize qa runtime parity gate 2026-06-09 01:02:24 +02:00
Vincent Koc
bad449301f test(release): align kitchen sink rpc descriptors 2026-06-09 01:02:24 +02:00
Vincent Koc
2a611865f4 ci(release): retry Docker E2E image builds 2026-06-09 01:02:24 +02:00
Vincent Koc
1019b591d5 test(release): stabilize qa gateway restart readiness 2026-06-09 01:02:24 +02:00
Vincent Koc
ff5fac1439 ci(release): retry Docker BuildKit bootstrap 2026-06-09 01:02:24 +02:00
Vincent Koc
f29248fa62 ci(release): retry transient registry build failures 2026-06-09 01:02:23 +02:00
Vincent Koc
04b8c4f313 test(release): isolate trajectory export migration checks 2026-06-09 01:02:23 +02:00
Vincent Koc
f1a1fce982 test(release): isolate sandbox explain migration checks 2026-06-09 01:02:23 +02:00
Vincent Koc
9faa741536 test(release): isolate default session store migration tests 2026-06-09 01:02:23 +02:00
Vincent Koc
dc51c57e29 test(release): isolate sessions tail migration checks 2026-06-09 01:02:23 +02:00
Vincent Koc
95c72dde0f test(release): isolate sessions command migration tests 2026-06-09 01:02:23 +02:00
Vincent Koc
37c1e2725a test(release): stabilize beta three command shards 2026-06-09 01:02:23 +02:00
Vincent Koc
06b226e8b5 test(release): stabilize beta three validation 2026-06-09 01:02:23 +02:00
Vincent Koc
3ed8d5f2c3 fix(tasks): keep maintenance migration scoped 2026-06-09 01:02:23 +02:00
Vincent Koc
2d5bf186c1 test(release): stabilize task maintenance checks 2026-06-09 01:02:23 +02:00
Vincent Koc
3a2176267c test(release): stabilize beta three validation 2026-06-09 01:02:23 +02:00
Vincent Koc
4b55a0e04d test(release): clear beta validation blockers 2026-06-09 01:02:23 +02:00
Vincent Koc
9cdf853409 test(release): stabilize beta validation checks 2026-06-09 01:02:23 +02:00
Vincent Koc
cff8154954 fix(release): satisfy control ui registry lint 2026-06-09 01:02:22 +02:00
Vincent Koc
55de547b52 test(release): keep workshop state mocks current 2026-06-09 01:02:22 +02:00
Vincent Koc
505b23a137 fix(release): clear beta validation blockers 2026-06-09 01:02:22 +02:00
Vincent Koc
5496044f6d fix(release): cap docker e2e cpus 2026-06-09 01:02:22 +02:00
Vincent Koc
20604f7a8f test(memory-core): seed dreaming session store 2026-06-09 01:02:22 +02:00
Vincent Koc
a0f76b2b25 test(telegram): seed approval session store 2026-06-09 01:02:22 +02:00
Vincent Koc
fb97b3b4b3 test(discord): seed think autocomplete session store 2026-06-09 01:02:22 +02:00
Dallin Romney
112e98faa2 chore: bump codex app-server to 0.137.0 (#91496) 2026-06-08 15:42:41 -07:00
dependabot[bot]
646bc0d274 build(deps): bump the android-deps group in /apps/android with 3 updates (#91365)
* build(deps): bump the android-deps group in /apps/android with 3 updates

Bumps the android-deps group in /apps/android with 3 updates: androidx.core:core-ktx, [org.jetbrains.kotlin.plugin.compose](https://github.com/JetBrains/kotlin) and [org.jetbrains.kotlin.plugin.serialization](https://github.com/JetBrains/kotlin).


Updates `androidx.core:core-ktx` from 1.18.0 to 1.19.0

Updates `org.jetbrains.kotlin.plugin.compose` from 2.3.21 to 2.4.0
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.0)

Updates `org.jetbrains.kotlin.plugin.serialization` from 2.3.21 to 2.4.0
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.0)

Updates `org.jetbrains.kotlin.plugin.serialization` from 2.3.21 to 2.4.0
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.0)

---
updated-dependencies:
- dependency-name: androidx.core:core-ktx
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(android): support compile SDK 37

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-08 15:34:28 -07:00
dependabot[bot]
c967172f69 build(deps): bump the actions group with 2 updates (#91367)
Bumps the actions group with 2 updates: [github/codeql-action](https://github.com/github/codeql-action) and [docker/login-action](https://github.com/docker/login-action).


Updates `github/codeql-action` from 4.36.1 to 4.36.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4.36.1...v4.36.2)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...650006c6eb)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 15:03:30 -07:00
dependabot[bot]
6aa89bb5f8 build(deps): bump actions/cache from 4 to 5 (#91369)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 15:00:44 -07:00
Kevin Lin
a54f50a41b chore: add taxonomy file (#91512)
* chore: add taxonomy file

* add maturity scores

* move taxonomy doc
2026-06-08 14:55:44 -07:00
dependabot[bot]
f9f7475dbf build(deps): bump actions/github-script from 8 to 9 (#91368)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 14:54:48 -07:00
dependabot[bot]
b875c812f7 build(deps): bump github.com/steipete/peekaboo (#91364)
Bumps the swift-deps group in /apps/macos with 1 update: [github.com/steipete/peekaboo](https://github.com/steipete/Peekaboo).


Updates `github.com/steipete/peekaboo` from 3.3.0 to 3.4.0
- [Release notes](https://github.com/steipete/Peekaboo/releases)
- [Commits](https://github.com/steipete/Peekaboo/compare/v3.3.0...v3.4.0)

---
updated-dependencies:
- dependency-name: github.com/steipete/peekaboo
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: swift-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 14:54:44 -07:00
Peter Steinberger
6c4fb997e5 fix: refresh npm shrinkwrap 2026-06-08 22:34:23 +01:00
355 changed files with 36309 additions and 6244 deletions

4
.github/labeler.yml vendored
View File

@@ -293,6 +293,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/lobster/**"
"extensions: llama-cpp":
- changed-files:
- any-glob-to-any-file:
- "extensions/llama-cpp/**"
"extensions: memory-core":
- changed-files:
- any-glob-to-any-file:

View File

@@ -2093,7 +2093,7 @@ jobs:
uses: actions/cache@v5
with:
path: ~/.android-sdk
key: ${{ runner.os }}-android-sdk-v1-cmdline-12266719-platform-36-build-tools-36.0.0
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
restore-keys: |
${{ runner.os }}-android-sdk-v1-
@@ -2101,7 +2101,7 @@ jobs:
run: |
set -euo pipefail
ANDROID_SDK_ROOT="$HOME/.android-sdk"
CMDLINE_TOOLS_VERSION="12266719"
CMDLINE_TOOLS_VERSION="14742923"
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
URL="https://dl.google.com/android/repository/${ARCHIVE}"
@@ -2123,7 +2123,7 @@ jobs:
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
"platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Run Android ${{ matrix.task }}

View File

@@ -35,7 +35,7 @@ jobs:
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: java-kotlin
build-mode: manual
@@ -46,6 +46,6 @@ jobs:
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-security/android"

View File

@@ -342,13 +342,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/core-auth-secrets"
@@ -365,13 +365,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/config-boundary"
@@ -388,13 +388,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/gateway-runtime-boundary"
@@ -411,13 +411,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/channel-runtime-boundary"
@@ -460,7 +460,7 @@ jobs:
- name: Initialize CodeQL
if: ${{ github.event_name != 'pull_request' }}
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml
@@ -468,7 +468,7 @@ jobs:
- name: Analyze
id: analyze
if: ${{ github.event_name != 'pull_request' }}
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
output: sarif-results
category: "/codeql-critical-quality/network-runtime-boundary"
@@ -518,13 +518,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/agent-runtime-boundary"
@@ -541,13 +541,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/mcp-process-runtime-boundary"
@@ -564,13 +564,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/memory-runtime-boundary"
@@ -587,13 +587,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/session-diagnostics-boundary"
@@ -610,13 +610,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/plugin-sdk-reply-runtime"
@@ -633,13 +633,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/provider-runtime-boundary"
@@ -655,13 +655,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/ui-control-plane"
@@ -677,13 +677,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/web-media-runtime-boundary"
@@ -700,13 +700,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/plugin-boundary"
@@ -723,12 +723,12 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-critical-quality/plugin-sdk-package-contract"

View File

@@ -35,7 +35,7 @@ jobs:
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: swift
build-mode: manual
@@ -46,7 +46,7 @@ jobs:
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
output: sarif-results
upload: failure-only
@@ -83,7 +83,7 @@ jobs:
done
- name: Upload filtered SARIF
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"

View File

@@ -101,12 +101,12 @@ jobs:
.github/codeql
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: ${{ matrix.language }}
config-file: ${{ matrix.config_file }}
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/codeql-security-high/${{ matrix.category }}"

View File

@@ -88,11 +88,30 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
fetch-depth: 0
- name: Pre-pull BuildKit image
shell: bash
env:
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
run: |
set -euo pipefail
for attempt in 1 2 3 4; do
if docker pull "${BUILDKIT_IMAGE}"; then
exit 0
fi
if [[ "${attempt}" == "4" ]]; then
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 10))
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "${sleep_seconds}"
done
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
@@ -279,11 +298,30 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
fetch-depth: 0
- name: Pre-pull BuildKit image
shell: bash
env:
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
run: |
set -euo pipefail
for attempt in 1 2 3 4; do
if docker pull "${BUILDKIT_IMAGE}"; then
exit 0
fi
if [[ "${attempt}" == "4" ]]; then
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 10))
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "${sleep_seconds}"
done
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
@@ -468,7 +506,7 @@ jobs:
fetch-depth: 0
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
@@ -561,11 +599,30 @@ jobs:
with:
fetch-depth: 1
- name: Pre-pull BuildKit image
shell: bash
env:
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
run: |
set -euo pipefail
for attempt in 1 2 3 4; do
if docker pull "${BUILDKIT_IMAGE}"; then
exit 0
fi
if [[ "${attempt}" == "4" ]]; then
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 10))
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "${sleep_seconds}"
done
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}

View File

@@ -223,7 +223,7 @@ jobs:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -311,7 +311,7 @@ jobs:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -417,7 +417,7 @@ jobs:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -503,7 +503,7 @@ jobs:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -29,7 +29,7 @@ jobs:
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -37,7 +37,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);

View File

@@ -56,7 +56,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -91,7 +91,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const defaultBaseline = "0bf06e953fdda290799fc9fb9244a8f67fdae593";
@@ -581,7 +581,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -56,7 +56,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -91,7 +91,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const defaultBaseline = "synthetic-reverted-thread-filepath-fix";
@@ -603,7 +603,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -81,7 +81,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -180,7 +180,7 @@ jobs:
run: pnpm build
- name: Cache Mantis candidate pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.local/share/pnpm/store

View File

@@ -79,7 +79,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
if (context.eventName === "pull_request_target") {
@@ -125,7 +125,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const eventName = context.eventName;
@@ -709,7 +709,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -68,7 +68,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -105,7 +105,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const eventName = context.eventName;
@@ -327,7 +327,7 @@ jobs:
run: pnpm build
- name: Cache Mantis candidate pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.local/share/pnpm/store
@@ -573,7 +573,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -1503,31 +1503,66 @@ jobs:
- name: Build and push bare Docker E2E image
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
target: bare
platforms: linux/amd64
tags: ${{ steps.image.outputs.bare_image }}
sbom: true
provenance: mode=max
push: true
shell: bash
env:
IMAGE_REF: ${{ steps.image.outputs.bare_image }}
run: |
set -euo pipefail
build_cmd=(
docker buildx build
--file ./scripts/e2e/Dockerfile
--target bare
--platform linux/amd64
--tag "$IMAGE_REF"
--sbom=true
--provenance=mode=max
--push
.
)
for attempt in 1 2 3 4; do
if "${build_cmd[@]}"; then
exit 0
fi
if [[ "$attempt" == "4" ]]; then
echo "::error::Failed to build Docker E2E bare image after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 20))
echo "Docker E2E bare image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "$sleep_seconds"
done
- name: Build and push functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
target: functional
build-contexts: |
openclaw_package=.artifacts/docker-e2e-package
platforms: linux/amd64
tags: ${{ steps.image.outputs.functional_image }}
sbom: true
provenance: mode=max
push: true
shell: bash
env:
IMAGE_REF: ${{ steps.image.outputs.functional_image }}
run: |
set -euo pipefail
build_cmd=(
docker buildx build
--file ./scripts/e2e/Dockerfile
--target functional
--build-context openclaw_package=.artifacts/docker-e2e-package
--platform linux/amd64
--tag "$IMAGE_REF"
--sbom=true
--provenance=mode=max
--push
.
)
for attempt in 1 2 3 4; do
if "${build_cmd[@]}"; then
exit 0
fi
if [[ "$attempt" == "4" ]]; then
echo "::error::Failed to build Docker E2E functional image after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 20))
echo "Docker E2E functional image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "$sleep_seconds"
done
prepare_live_test_image:
needs: validate_selected_ref

View File

@@ -53,7 +53,7 @@ jobs:
scripts/run-opengrep.sh --sarif --error
- name: Upload SARIF to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v4.36.1
uses: github/codeql-action/upload-sarif@v4.36.2
# Only upload if the scan actually produced a SARIF file.
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
with:

View File

@@ -84,7 +84,7 @@ jobs:
scripts/run-opengrep.sh --changed --sarif --error
- name: Upload SARIF to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v4.36.1
uses: github/codeql-action/upload-sarif@v4.36.2
# Only upload if the scan actually produced a SARIF file.
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
with:

View File

@@ -24,6 +24,11 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
dry_run:
description: Validate the full ClawHub artifact handoff without publishing.
required: false
default: false
type: boolean
concurrency:
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -35,7 +40,7 @@ env:
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c"
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
jobs:
preview_plugins_clawhub:
@@ -56,12 +61,6 @@ jobs:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Resolve checked-out ref
id: ref
env:
@@ -107,6 +106,12 @@ jobs:
echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Validate publishable plugin metadata
env:
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
@@ -326,15 +331,12 @@ jobs:
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
publish_plugins_clawhub:
pack_plugins_clawhub_artifacts:
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
actions: read
contents: read
id-token: write
strategy:
fail-fast: false
max-parallel: 32
@@ -407,73 +409,7 @@ jobs:
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Write ClawHub token config
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
if [[ -z "${CLAWHUB_TOKEN}" ]]; then
echo "No CLAWHUB_TOKEN secret configured; publish will rely on GitHub OIDC trusted publishing."
exit 0
fi
node --input-type=module <<'EOF'
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const path = join(process.env.RUNNER_TEMP, "clawhub-config.json");
writeFileSync(
path,
`${JSON.stringify(
{
registry: process.env.CLAWHUB_REGISTRY,
token: process.env.CLAWHUB_TOKEN,
},
null,
2,
)}\n`,
);
console.log(path);
EOF
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
- name: Check ClawHub package version
id: clawhub_package_version
env:
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
status=""
for attempt in $(seq 1 8); do
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then
break
fi
if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then
echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)."
sleep 60
continue
fi
break
done
if [[ "${status}" =~ ^2 ]]; then
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
echo "already_published=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${status}" != "404" ]]; then
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
exit 1
fi
echo "already_published=false" >> "$GITHUB_OUTPUT"
- name: Publish
if: steps.clawhub_package_version.outputs.already_published != 'true'
- name: Pack ClawHub package artifact
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
@@ -481,8 +417,65 @@ jobs:
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact
run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}"
- name: Upload ClawHub package artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.plugin.artifactName }}
path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz
if-no-files-found: error
retention-days: 7
approve_plugin_clawhub_release:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions: {}
steps:
- name: Approve ClawHub package publish
run: echo "ClawHub package publish approved."
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
permissions:
actions: read
contents: read
id-token: write
strategy:
fail-fast: false
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
with:
dry_run: ${{ inputs.dry_run }}
json: true
package_artifact_name: ${{ matrix.plugin.artifactName }}
registry: https://clawhub.ai
site: https://clawhub.ai
source_repo: ${{ github.repository }}
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
source_ref: ${{ github.ref }}
tags: ${{ matrix.plugin.publishTag }}
secrets:
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
verify_published_clawhub_package:
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Verify published ClawHub package
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}

View File

@@ -65,7 +65,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
if (context.eventName === "schedule") {

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta.
- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
- QQBot: add `/bot-group-allways on|off` slash command (with named-account and default-account support) to toggle whether group messages require an `@mention` before the bot replies, and clear the runtime config snapshot after the write so the new account-level `defaultRequireMention` takes effect immediately without restart. (#91423) Thanks @cxyhhhhh.
### Fixes
@@ -39,6 +40,7 @@ Docs: https://docs.openclaw.ai
- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar.
- Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.
- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.

View File

@@ -41,7 +41,7 @@ plugins {
android {
namespace = "ai.openclaw.app"
compileSdk = 36
compileSdk = 37
// Release signing is local-only; keep the keystore path and passwords out of the repo.
signingConfigs {

View File

@@ -49,6 +49,19 @@ import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private fun createDnsResolver(context: Context): DnsResolver =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
createContextDnsResolver(context)
} else {
createLegacyDnsResolver()
}
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
@Suppress("DEPRECATION")
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
/**
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
*/
@@ -58,7 +71,7 @@ class GatewayDiscovery(
) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance()
private val dns = createDnsResolver(context)
private val serviceType = "_openclaw-gw._tcp."
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
private val logTag = "OpenClaw/GatewayDiscovery"

View File

@@ -5,7 +5,7 @@ plugins {
android {
namespace = "ai.openclaw.app.benchmark"
compileSdk = 36
compileSdk = 37
defaultConfig {
minSdk = 31

View File

@@ -4,7 +4,7 @@ androidx-activity = "1.13.0"
androidx-benchmark = "1.4.1"
androidx-camera = "1.6.0"
androidx-compose-bom = "2026.05.01"
androidx-core = "1.18.0"
androidx-core = "1.19.0"
androidx-exifinterface = "1.4.2"
androidx-lifecycle = "2.10.0"
androidx-security = "1.1.0"
@@ -19,7 +19,7 @@ junit = "4.13.2"
junit-vintage = "6.1.0"
kotest = "6.1.11"
ktlint-gradle = "14.2.0"
kotlin = "2.3.21"
kotlin = "2.4.0"
material = "1.14.0"
okhttp = "5.3.2"
play-services-code-scanner = "16.1.0"

View File

@@ -329,6 +329,13 @@ struct AgentConfigLite: Decodable {
struct ConfigPatchParams: Encodable {
let raw: String
let baseHash: String
let replacePaths: [String]?
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
self.raw = raw
self.baseHash = baseHash
self.replacePaths = replacePaths
}
}
enum SkillMutationError: LocalizedError {

View File

@@ -621,7 +621,10 @@ extension AgentProTab {
}
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
let params = ConfigPatchParams(
raw: raw,
baseHash: baseHash,
replacePaths: ["agents.list[].skills"])
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw SkillMutationError.invalidPatchPayload

View File

@@ -1,5 +1,5 @@
{
"originHash" : "a88730a64ccb5fd092108256c37d6c80bc7b92a5b6b563d83a9a26988550234d",
"originHash" : "035a4fe955164c62c1628de75f6437a14443a947eea2a1b0176ba484d6fde6f8",
"pins" : [
{
"identity" : "axorcist",
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"revision" : "faf843032772c2074d834b931911bf0002704136",
"version" : "3.3.0"
"revision" : "3a56ed2aa769bfefb5a78722dfce3c34088cfba1",
"version" : "3.4.0"
}
},
{

View File

@@ -19,7 +19,7 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.3.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.0"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../swabble"),
],

View File

@@ -2773,6 +2773,7 @@ public struct ConfigPatchParams: Codable, Sendable {
public let deliverycontext: [String: AnyCodable]?
public let note: String?
public let restartdelayms: Int?
public let replacepaths: [String]?
public init(
raw: String,
@@ -2780,7 +2781,8 @@ public struct ConfigPatchParams: Codable, Sendable {
sessionkey: String?,
deliverycontext: [String: AnyCodable]?,
note: String?,
restartdelayms: Int?)
restartdelayms: Int?,
replacepaths: [String]?)
{
self.raw = raw
self.basehash = basehash
@@ -2788,6 +2790,7 @@ public struct ConfigPatchParams: Codable, Sendable {
self.deliverycontext = deliverycontext
self.note = note
self.restartdelayms = restartdelayms
self.replacepaths = replacepaths
}
private enum CodingKeys: String, CodingKey {
@@ -2797,6 +2800,7 @@ public struct ConfigPatchParams: Codable, Sendable {
case deliverycontext = "deliveryContext"
case note
case restartdelayms = "restartDelayMs"
case replacepaths = "replacePaths"
}
}

View File

@@ -217,6 +217,16 @@ const config = {
entry: ["index.js!", "scripts/postinstall.js!"],
project: ["index.js!", "scripts/**/*.js!"],
},
[`${BUNDLED_PLUGIN_ROOT_DIR}/llama-cpp`]: {
entry: bundledPluginEntries,
project: ["index.ts!", "src/**/*.{js,mjs,ts}!"],
ignoreDependencies: [
// The provider resolves node-llama-cpp from its own package at runtime
// so local embeddings use the plugin-owned native dependency.
"node-llama-cpp",
...bundledPluginIgnoredRuntimeDependencies,
],
},
[`${BUNDLED_PLUGIN_ROOT_DIR}/*`]: {
// Bundled plugins often load their public surface via string specifiers in
// `index.ts` contracts, so Knip needs these convention-based entry files.

View File

@@ -1,2 +1,2 @@
de06fd99257e4b010e54578ea46605c3bc631c31cac5f68aaed4e301f924f8af plugin-sdk-api-baseline.json
1c7a5420c4bcb1ec08544ff43b83fa4d43f3c0dcda597a5a25aa5f5bab0cb199 plugin-sdk-api-baseline.jsonl
1cd5bcc75461c64d39a00918a50d033e66ae7ec199d8029f7cccaaa2eeb16f22 plugin-sdk-api-baseline.json
a5d3b43c3710c4238958b1b3163e652ac34bdc7b82215c6294ce61b72188d75e plugin-sdk-api-baseline.jsonl

View File

@@ -55,14 +55,6 @@
"source": "Mantis",
"target": "Mantis"
},
{
"source": "OpenClaw App SDK",
"target": "OpenClaw 应用 SDK"
},
{
"source": "OpenClaw App SDK API design",
"target": "OpenClaw 应用 SDK API 设计"
},
{
"source": "Message lifecycle refactor",
"target": "消息生命周期重构"

View File

@@ -39,9 +39,12 @@ To set a provider explicitly:
Without an embedding provider, only keyword search is available.
To force the built-in local embedding provider, install the optional
`node-llama-cpp` runtime package next to OpenClaw, then point `local.modelPath`
at a GGUF file:
To force local GGUF embeddings, install the official llama.cpp provider plugin,
then point `local.modelPath` at a GGUF file:
```bash
openclaw plugins install @openclaw/llama-cpp-provider
```
```json5
{
@@ -67,7 +70,7 @@ at a GGUF file:
| DeepInfra | `deepinfra` | Default: `BAAI/bge-m3` |
| Gemini | `gemini` | Supports multimodal (image + audio) |
| GitHub Copilot | `github-copilot` | Uses Copilot subscription |
| Local | `local` | Optional `node-llama-cpp` runtime |
| Local | `local` | `@openclaw/llama-cpp-provider` |
| Mistral | `mistral` | |
| Ollama | `ollama` | Local/self-hosted |
| OpenAI | `openai` | Default: `text-embedding-3-small` |

View File

@@ -15,7 +15,7 @@ binary, and can index content beyond your workspace memory files.
- **Reranking and query expansion** for better recall.
- **Index extra directories** -- project docs, team notes, anything on disk.
- **Index session transcripts** -- recall earlier conversations.
- **Fully local** -- runs with the optional node-llama-cpp runtime package and
- **Fully local** -- runs with the official llama.cpp provider plugin and
auto-downloads GGUF models.
- **Automatic fallback** -- if QMD is unavailable, OpenClaw falls back to the
builtin engine seamlessly.

View File

@@ -32,7 +32,8 @@ For multi-endpoint setups with memory-specific providers, `provider` can also
be a custom `models.providers.<id>` entry, such as `ollama-5080`, when that
provider sets `api: "ollama"` or another memory embedding adapter owner.
For local embeddings with no API key, set `provider: "local"`. Source checkouts
For local embeddings with no API key, install
`@openclaw/llama-cpp-provider` and set `provider: "local"`. Source checkouts
may still require native build approval: `pnpm approve-builds` then
`pnpm rebuild node-llama-cpp`.

View File

@@ -1,323 +0,0 @@
---
summary: "Public OpenClaw App SDK for external apps, scripts, dashboards, CI jobs, and IDE extensions"
title: "OpenClaw App SDK"
sidebarTitle: "App SDK"
read_when:
- You are building an external app, script, dashboard, CI job, or IDE extension that talks to OpenClaw
- You are choosing between the App SDK and the Plugin SDK
- You are integrating with Gateway agent runs, sessions, events, approvals, models, or tools
---
The **OpenClaw App SDK** is the public client API for apps outside the
OpenClaw process. Use `@openclaw/sdk` when a script, dashboard, CI job, IDE
extension, or other external app wants to connect to the Gateway, start agent
runs, stream events, wait for results, cancel work, or inspect Gateway
resources.
<Note>
The App SDK is different from the [Plugin SDK](/plugins/sdk-overview).
`@openclaw/sdk` talks to the Gateway from outside OpenClaw.
`openclaw/plugin-sdk/*` is only for plugins that run inside OpenClaw and
register providers, channels, tools, hooks, or trusted runtimes.
</Note>
## What ships today
`@openclaw/sdk` ships with:
| Surface | Status | What it does |
| ------------------------- | ------- | --------------------------------------------------------------------------------- |
| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. |
| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. |
| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. |
| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. |
| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. |
| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. |
| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. |
| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. |
| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. |
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
| `oc.tasks` | Ready | Lists, reads, and cancels Gateway task ledger entries. |
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
| `oc.tools` | Ready | Lists, scopes, and invokes Gateway tools through the policy pipeline. |
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
| `oc.environments` | Partial | Lists Gateway-local and node environment candidates; create/delete are not wired. |
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
The SDK also exports the core types used by those surfaces:
`AgentRunParams`, `RunResult`, `RunStatus`, `OpenClawEvent`,
`OpenClawEventType`, `GatewayEvent`, `OpenClawTransport`,
`GatewayRequestOptions`, `SessionCreateParams`, `SessionSendParams`,
`ArtifactSummary`, `ArtifactQuery`, `ArtifactsListResult`,
`ArtifactsGetResult`, `ArtifactsDownloadResult`,
`TaskSummary`, `TaskStatus`, `TasksListParams`, `TasksListResult`,
`TasksGetResult`, `TasksCancelResult`, `RuntimeSelection`,
`EnvironmentSelection`, `WorkspaceSelection`, `ApprovalMode`, and related
result types.
## Connect to a Gateway
Create a client with an explicit Gateway URL, or inject a custom transport for
tests and embedded app runtimes.
```typescript
import { OpenClaw } from "@openclaw/sdk";
const oc = new OpenClaw({
url: "ws://127.0.0.1:18789",
token: process.env.OPENCLAW_GATEWAY_TOKEN,
requestTimeoutMs: 30_000,
});
await oc.connect();
```
`new OpenClaw({ gateway: "ws://..." })` is equivalent to `url`. The
`gateway: "auto"` option is accepted by the constructor, but automatic Gateway
discovery is not a separate SDK feature yet; pass `url` when the app does not
already know how to discover the Gateway.
For tests, pass an object that implements `OpenClawTransport`:
```typescript
const oc = new OpenClaw({
transport: {
async request(method, params) {
return { method, params };
},
async *events() {},
},
});
```
## Run an agent
Use `oc.agents.get(id)` when the app wants an agent handle, then call
`agent.run()`.
```typescript
const agent = await oc.agents.get("main");
const run = await agent.run({
input: "Review this pull request and suggest the smallest safe fix.",
model: "openai/gpt-5.5",
sessionKey: "main",
timeoutMs: 30_000,
});
for await (const event of run.events()) {
const data = event.data as { delta?: unknown };
if (event.type === "assistant.delta" && typeof data.delta === "string") {
process.stdout.write(data.delta);
}
}
const result = await run.wait({ timeoutMs: 120_000 });
console.log(result.status);
```
Provider-qualified model refs such as `openai/gpt-5.5` are split into Gateway
`provider` and `model` overrides. `timeoutMs` stays milliseconds in the SDK and
is converted to Gateway timeout seconds for the `agent` RPC.
`run.wait()` uses the Gateway `agent.wait` RPC. A wait deadline that expires
while the run is still active returns `status: "accepted"` instead of pretending
the run itself timed out. Runtime timeouts, aborted runs, and cancelled runs are
normalized into `timed_out` or `cancelled`.
## Create and reuse sessions
Use sessions when the app wants durable transcript state.
```typescript
const session = await oc.sessions.create({
agentId: "main",
label: "release-review",
});
const run = await session.send("Prepare release notes from the current diff.");
await run.wait();
```
`Session.send()` calls `sessions.send` and returns a `Run`. Session handles also
support:
```typescript
await session.abort(run.id);
await session.patch({ label: "renamed-session" });
await session.compact({ maxLines: 200 });
```
## Stream events
The SDK normalizes raw Gateway events into a stable `OpenClawEvent` envelope:
```typescript
type OpenClawEvent = {
version: 1;
id: string;
ts: number;
type: OpenClawEventType;
runId?: string;
sessionId?: string;
sessionKey?: string;
taskId?: string;
agentId?: string;
data: unknown;
raw?: GatewayEvent;
};
```
Common event types include:
| Event type | Source Gateway event |
| --------------------- | ------------------------------------------- |
| `run.started` | `agent` lifecycle start |
| `run.completed` | `agent` lifecycle end |
| `run.failed` | `agent` lifecycle error |
| `run.cancelled` | Aborted/cancelled lifecycle end |
| `run.timed_out` | Timeout lifecycle end |
| `assistant.delta` | Assistant streaming delta |
| `assistant.message` | Assistant message |
| `thinking.delta` | Thinking or plan stream |
| `tool.call.started` | Tool/item/command start |
| `tool.call.delta` | Tool/item/command update |
| `tool.call.completed` | Tool/item/command completion |
| `tool.call.failed` | Tool/item/command failure or blocked status |
| `approval.requested` | Exec or plugin approval request |
| `approval.resolved` | Exec or plugin approval resolution |
| `session.created` | `sessions.changed` create |
| `session.updated` | `sessions.changed` update |
| `session.compacted` | `sessions.changed` compaction |
| `task.updated` | Task update events |
| `artifact.updated` | Patch stream events |
| `raw` | Any event without a stable SDK mapping yet |
`Run.events()` filters events to one run id and replays already-seen events for
fast runs. That means the documented flow is safe:
```typescript
const run = await agent.run("Summarize the latest session.");
for await (const event of run.events()) {
if (event.type === "run.completed") {
break;
}
}
```
For app-wide streams, use `oc.events()`. For raw Gateway frames, use
`oc.rawEvents()`.
## Models, tools, artifacts, and approvals
Model helpers map to current Gateway methods:
```typescript
await oc.models.list();
await oc.models.status({ probe: false }); // calls models.authStatus
```
Tool helpers expose the Gateway catalog, effective tool view, and direct
Gateway tool invocation. `oc.tools.invoke()` returns a typed envelope instead
of throwing for policy or approval refusals.
```typescript
await oc.tools.list();
await oc.tools.effective({ sessionKey: "main" });
await oc.tools.invoke("tool-name", {
args: { input: "value" },
sessionKey: "main",
confirm: false,
idempotencyKey: "tool-call-1",
});
```
Artifact helpers expose the Gateway artifact projection for session, run, or
task context. Each call requires one explicit `sessionKey`, `runId`, or
`taskId` scope:
```typescript
const { artifacts } = await oc.artifacts.list({ sessionKey: "main" });
const first = artifacts[0];
if (first) {
const { artifact } = await oc.artifacts.get(first.id, { sessionKey: "main" });
const download = await oc.artifacts.download(artifact.id, { sessionKey: "main" });
console.log(download.encoding, download.url);
}
```
Approval helpers use the exec approval RPCs:
```typescript
const approvals = await oc.approvals.list();
await oc.approvals.respond("approval-id", { decision: "approve" });
```
Task helpers use the durable task ledger that also backs `openclaw tasks`:
```typescript
const tasks = await oc.tasks.list({ status: "running", sessionKey: "agent:main:main" });
const task = await oc.tasks.get(tasks.tasks[0].id);
await oc.tasks.cancel(task.task.id, { reason: "user stopped task" });
```
Environment helpers expose read-only Gateway-local and node discovery:
```typescript
const { environments } = await oc.environments.list();
await oc.environments.status(environments[0].id);
```
## Explicitly unsupported today
The SDK includes names for the product model we want, but it does not silently
pretend Gateway RPCs exist. These calls currently throw explicit unsupported
errors:
```typescript
await oc.environments.create({});
await oc.environments.delete("environment-id");
```
Per-run `workspace`, `runtime`, `environment`, and `approvals` fields are typed
as future shape, but the current Gateway does not support those overrides on
the `agent` RPC. If callers pass them, the SDK throws before submitting the run
so work does not accidentally execute with default workspace, runtime,
environment, or approval behavior.
## App SDK vs Plugin SDK
Use the App SDK when code lives outside OpenClaw:
- Node scripts that start or observe agent runs
- CI jobs that call a Gateway
- dashboards and admin panels
- IDE extensions
- external bridges that do not need to become channel plugins
- integration tests with fake or real Gateway transports
Use the Plugin SDK when code runs inside OpenClaw:
- provider plugins
- channel plugins
- tool or lifecycle hooks
- agent harness plugins
- trusted runtime helpers
App SDK code should import from `@openclaw/sdk`. Plugin code should import from
documented `openclaw/plugin-sdk/*` subpaths. Do not mix the two contracts.
## Related
- [OpenClaw App SDK API design](/reference/openclaw-sdk-api-design)
- [Gateway RPC reference](/reference/rpc)
- [Agent loop](/concepts/agent-loop)
- [Agent runtimes](/concepts/agent-runtimes)
- [Sessions](/concepts/session)
- [Background tasks](/automation/tasks)
- [ACP agents](/tools/acp-agents)
- [Plugin SDK overview](/plugins/sdk-overview)

View File

@@ -60,6 +60,14 @@
"source": "/install/migrating-matrix",
"destination": "/channels/matrix-migration"
},
{
"source": "/concepts/openclaw-sdk",
"destination": "/gateway/external-apps"
},
{
"source": "/reference/openclaw-sdk-api-design",
"destination": "/gateway/external-apps"
},
{
"source": "/mcp",
"destination": "/cli/mcp"
@@ -1241,6 +1249,7 @@
"plugins/admin-http-rpc",
"plugins/voice-call",
"plugins/memory-wiki",
"plugins/llama-cpp",
"plugins/memory-lancedb",
"plugins/oc-path",
"plugins/zalouser"
@@ -1741,8 +1750,7 @@
"group": "RPC and API",
"pages": [
"reference/rpc",
"concepts/openclaw-sdk",
"reference/openclaw-sdk-api-design",
"gateway/external-apps",
"reference/code-mode",
"reference/device-models"
]

View File

@@ -601,7 +601,8 @@ For tooling that writes config over the gateway API, prefer this flow:
summaries)
- `config.get` to fetch the current snapshot plus `hash`
- `config.patch` for partial updates (JSON merge patch: objects merge, `null`
deletes, arrays replace)
deletes, arrays replace when explicitly confirmed with `replacePaths` if
entries would be removed)
- `config.apply` only when you intend to replace the entire config
- `update.run` for explicit self-update plus restart; include `continuationMessage` when the post-restart session should run one follow-up turn
- `update.status` to inspect the latest update restart sentinel and verify the running version after a restart
@@ -633,6 +634,14 @@ Both `config.apply` and `config.patch` accept `raw`, `baseHash`, `sessionKey`,
`note`, and `restartDelayMs`. `baseHash` is required for both methods when a
config already exists.
`config.patch` also accepts `replacePaths`, an array of config paths whose array
replacement is intentional. If a patch would replace or delete an existing array
with fewer entries, the Gateway rejects the write unless that exact path appears
in `replacePaths`; nested arrays under array entries use `[]`, such as
`agents.list[].skills`. This prevents truncated `config.get` snapshots from
silently clobbering routing or allowlist arrays. Use `config.apply` when you
intend to replace the full config.
## Environment variables
OpenClaw reads env vars from the parent process plus:

View File

@@ -0,0 +1,86 @@
---
summary: "Current integration path for external apps, scripts, dashboards, CI jobs, and IDE extensions"
title: "Gateway integrations for external apps"
sidebarTitle: "External apps"
read_when:
- You are building an external app, script, dashboard, CI job, or IDE extension that talks to OpenClaw
- You are choosing between Gateway RPC and the Plugin SDK
- You are integrating with Gateway agent runs, sessions, events, approvals, models, or tools
---
External apps should talk to OpenClaw through the Gateway protocol today. Use
Gateway WebSocket and RPC methods when a script, dashboard, CI job, IDE
extension, or another process wants to start agent runs, stream events, wait for
results, cancel work, or inspect Gateway resources.
<Warning>
There is no public npm client package yet. Do not add OpenClaw client package
names as application dependencies until release notes announce a published
package and this page includes install instructions.
</Warning>
<Note>
This page is for code outside the OpenClaw process. Plugin code that runs
inside OpenClaw should use documented `openclaw/plugin-sdk/*` subpaths instead.
</Note>
## What is available today
| Surface | Status | Use it for |
| --------------------------------------- | ------ | --------------------------------------------------------------------------------------------- |
| [Gateway protocol](/gateway/protocol) | Ready | WebSocket transport, connect handshake, auth scopes, protocol versioning, and events. |
| [Gateway RPC reference](/reference/rpc) | Ready | Current Gateway methods for agents, sessions, tasks, models, tools, artifacts, and approvals. |
| [`openclaw agent`](/cli/agent) | Ready | One-shot script integration when shelling out to the CLI is enough. |
| [`openclaw message`](/cli/message) | Ready | Sending messages or channel actions from scripts. |
The source tree contains internal package work for a future client library, but
that is not a public install surface. Treat it as preview implementation detail
until the packages are published and versioned.
## Recommended path
1. Run or discover a Gateway.
2. Connect over the [Gateway protocol](/gateway/protocol).
3. Call documented RPC methods from [Gateway RPC reference](/reference/rpc).
4. Pin the OpenClaw version you test against.
5. Recheck the RPC reference when upgrading OpenClaw.
For agent runs, start with the `agent` RPC and pair it with `agent.wait` when
you need a terminal result. For durable conversation state, use the `sessions.*`
methods. For UI integrations, subscribe to Gateway events and render only the
event families your app understands.
## App code vs plugin code
Use Gateway RPC when code lives outside OpenClaw:
- Node scripts that start or observe agent runs
- CI jobs that call a Gateway
- dashboards and admin panels
- IDE extensions
- external bridges that do not need to become channel plugins
- integration tests with fake or real Gateway transports
Use the Plugin SDK when code runs inside OpenClaw:
- provider plugins
- channel plugins
- tool or lifecycle hooks
- agent harness plugins
- trusted runtime helpers
External apps should not import `openclaw/plugin-sdk/*`; those subpaths are for
plugins loaded by OpenClaw.
## Related
- [Gateway protocol](/gateway/protocol)
- [Gateway RPC reference](/reference/rpc)
- [CLI agent command](/cli/agent)
- [CLI message command](/cli/message)
- [Agent loop](/concepts/agent-loop)
- [Agent runtimes](/concepts/agent-runtimes)
- [Sessions](/concepts/session)
- [Background tasks](/automation/tasks)
- [ACP agents](/tools/acp-agents)
- [Plugin SDK overview](/plugins/sdk-overview)

View File

@@ -405,7 +405,9 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
- `config.get` returns the current config snapshot and hash.
- `config.set` writes a validated config payload.
- `config.patch` merges a partial config update.
- `config.patch` merges a partial config update. Destructive array
replacement requires the affected path in `replacePaths`; nested arrays
under array entries use `[]` paths such as `agents.list[].skills`.
- `config.apply` validates + replaces the full config payload.
- `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
- `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.

5361
docs/maturity-scores.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -373,17 +373,16 @@ If discovery fails or times out, OpenClaw uses a bundled fallback catalog for:
- GPT-5.4 mini
- GPT-5.2
The current bundled harness is `@openai/codex` `0.135.0`. A `model/list` probe
The current bundled harness is `@openai/codex` `0.137.0`. A `model/list` probe
against that bundled app-server returned:
| Model id | Default | Hidden | Input modalities | Reasoning efforts |
| --------------------- | ------- | ------ | ---------------- | ------------------------ |
| `gpt-5.5` | Yes | No | text, image | low, medium, high, xhigh |
| `gpt-5.4` | No | No | text, image | low, medium, high, xhigh |
| `gpt-5.4-mini` | No | No | text, image | low, medium, high, xhigh |
| `gpt-5.3-codex` | No | No | text, image | low, medium, high, xhigh |
| `gpt-5.3-codex-spark` | No | No | text | low, medium, high, xhigh |
| `gpt-5.2` | No | No | text, image | low, medium, high, xhigh |
| Model id | Default | Hidden | Input modalities | Reasoning efforts |
| --------------- | ------- | ------ | ---------------- | ------------------------ |
| `gpt-5.5` | Yes | No | text, image | low, medium, high, xhigh |
| `gpt-5.4` | No | No | text, image | low, medium, high, xhigh |
| `gpt-5.4-mini` | No | No | text, image | low, medium, high, xhigh |
| `gpt-5.3-codex` | No | No | text, image | low, medium, high, xhigh |
| `gpt-5.2` | No | No | text, image | low, medium, high, xhigh |
Hidden models can be returned by the app-server catalog for internal or
specialized flows, but they are not normal model-picker choices.

58
docs/plugins/llama-cpp.md Normal file
View File

@@ -0,0 +1,58 @@
---
summary: "Install the official llama.cpp provider for local GGUF memory embeddings"
read_when:
- You want memory search embeddings from a local GGUF model
- You are configuring memorySearch.provider = "local"
- You need the OpenClaw plugin that owns the node-llama-cpp runtime
title: "llama.cpp Provider"
sidebarTitle: "llama.cpp Provider"
---
`llama-cpp` is the official external provider plugin for local GGUF embeddings.
It owns the `node-llama-cpp` runtime dependency used by
`memorySearch.provider: "local"`.
Install it before using local memory embeddings:
```bash
openclaw plugins install @openclaw/llama-cpp-provider
```
The main `openclaw` npm package does not include `node-llama-cpp`. Keeping the
native dependency in this plugin prevents normal OpenClaw npm updates from
deleting a manually installed runtime inside the OpenClaw package directory.
## Configuration
Set the memory search provider to `local`:
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "local",
local: {
modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf",
},
},
},
},
}
```
The default model is `embeddinggemma-300m-qat-Q8_0.gguf`. You can also point
`local.modelPath` at a local `.gguf` file.
## Native Runtime
Use Node 24 for the smoothest native install path. Source checkouts using pnpm
may need to approve and rebuild the native dependency:
```bash
pnpm approve-builds
pnpm rebuild node-llama-cpp
```
For lower-friction local embeddings, use a local service provider such as
Ollama or LM Studio instead.

View File

@@ -137,7 +137,7 @@ Each entry lists the package, distribution route, and description.
- **[mattermost](/plugins/reference/mattermost)** (`@openclaw/mattermost`) - included in OpenClaw. Adds the Mattermost channel surface for sending and receiving OpenClaw messages.
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds memory embedding provider support. Adds agent-callable tools.
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds file-backed memory search tools.
- **[memory-wiki](/plugins/reference/memory-wiki)** (`@openclaw/memory-wiki`) - included in OpenClaw. Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.
@@ -235,7 +235,7 @@ Each entry lists the package, distribution route, and description.
## Official external packages
34 plugins
35 plugins
- **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management.
@@ -267,6 +267,8 @@ Each entry lists the package, distribution route, and description.
- **[googlechat](/plugins/reference/googlechat)** (`@openclaw/googlechat`) - npm; ClawHub. OpenClaw Google Chat channel plugin for spaces and direct messages.
- **[llama-cpp](/plugins/reference/llama-cpp)** (`@openclaw/llama-cpp-provider`) - npm; ClawHub. OpenClaw llama.cpp embedding provider plugin.
- **[line](/plugins/reference/line)** (`@openclaw/line`) - npm; ClawHub. OpenClaw LINE channel plugin for LINE Bot API chats.
- **[lobster](/plugins/reference/lobster)** (`@openclaw/lobster`) - npm; ClawHub. Lobster workflow tool plugin for typed pipelines and resumable approvals.

View File

@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 126
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
generated plugin reference pages by distribution, package, and description.

View File

@@ -0,0 +1,23 @@
---
summary: "OpenClaw llama.cpp embedding provider plugin."
read_when:
- You are installing, configuring, or auditing the llama-cpp plugin
title: "llama-cpp plugin"
---
# llama-cpp plugin
OpenClaw llama.cpp embedding provider plugin.
## Distribution
- Package: `@openclaw/llama-cpp-provider`
- Install route: npm; ClawHub
## Surface
contracts: embeddingProviders
## Related docs
- [llama.cpp Provider](/plugins/llama-cpp)

View File

@@ -1,5 +1,5 @@
---
summary: "Adds memory embedding provider support. Adds agent-callable tools."
summary: "Adds file-backed memory search tools."
read_when:
- You are installing, configuring, or auditing the memory-core plugin
title: "Memory Core plugin"
@@ -7,7 +7,7 @@ title: "Memory Core plugin"
# Memory Core plugin
Adds memory embedding provider support. Adds agent-callable tools.
Adds file-backed memory search tools.
## Distribution
@@ -16,4 +16,4 @@ Adds memory embedding provider support. Adds agent-callable tools.
## Surface
contracts: memoryEmbeddingProviders, tools
contracts: tools

View File

@@ -1,5 +1,5 @@
---
summary: "Adds Microsoft Foundry model provider support to OpenClaw."
summary: "Use Microsoft Foundry chat and MAI image deployments from OpenClaw."
read_when:
- You are installing, configuring, or auditing the microsoft-foundry plugin
title: "Microsoft Foundry plugin"
@@ -7,7 +7,9 @@ title: "Microsoft Foundry plugin"
# Microsoft Foundry plugin
Adds Microsoft Foundry model provider support to OpenClaw.
Use Microsoft Foundry deployments from OpenClaw with API-key auth or Microsoft
Entra ID through the Azure CLI. The plugin owns Microsoft Foundry model
discovery, runtime token refresh, and MAI image generation.
## Distribution
@@ -16,4 +18,90 @@ Adds Microsoft Foundry model provider support to OpenClaw.
## Surface
providers: microsoft-foundry
- Model provider: `microsoft-foundry`
- Image-generation provider: `microsoft-foundry`
## Requirements
- A Microsoft Foundry or Azure AI Foundry resource with deployments.
- API-key auth through `AZURE_OPENAI_API_KEY` or a configured provider API key.
- For Entra ID auth, install the Azure CLI and run `az login` before
onboarding. OpenClaw refreshes Microsoft Foundry runtime tokens through
`az account get-access-token`.
## Chat models
Microsoft Foundry chat deployments use the provider model ref
`microsoft-foundry/<deployment-name>`. Onboarding discovers Foundry resources
and deployments with the Azure CLI, then writes the selected deployment name to
the model config.
OpenClaw uses the Foundry `/openai/v1` endpoint for supported OpenAI-compatible
chat APIs:
- GPT, `o*`, `computer-use-preview`, and DeepSeek-V4 model families default to
`openai-responses`.
- MAI-DS-R1 and other chat-completion deployments use `openai-completions`
unless an explicit supported API is configured.
- MAI-DS-R1 is recorded as reasoning-capable through reasoning content, not
through `reasoning_effort`. Its context and output token metadata are
163,840 tokens.
Anthropic Claude deployments in Microsoft Foundry use the Anthropic Messages
API shape, not the OpenAI-compatible `/openai/v1` shape. Configure those as a
custom `anthropic-messages` provider until the Microsoft Foundry plugin grows a
native Anthropic runtime.
## MAI image generation
The plugin registers `microsoft-foundry` for `image_generate` with the current
Microsoft AI image models:
- `MAI-Image-2.5-Flash`
- `MAI-Image-2.5`
- `MAI-Image-2e`
- `MAI-Image-2`
Use a deployed MAI image deployment name as the model ref. The provider does
not declare a default image model because the MAI API requires your deployment
name in the request `model` field:
```json5
{
agents: {
defaults: {
imageGenerationModel: {
primary: "microsoft-foundry/<deployment-name>",
timeoutMs: 600000,
},
},
},
}
```
Prompt-only generation calls Microsoft Foundry's MAI generations endpoint:
`/mai/v1/images/generations`. Reference-image edits call
`/mai/v1/images/edits` and are limited to `MAI-Image-2.5-Flash` and
`MAI-Image-2.5` deployments.
Prompt-only generation can use a custom deployment name with just the Foundry
endpoint configured. For image edits with a custom deployment name, select the
deployment through onboarding or include model metadata so OpenClaw can verify
that the deployment is backed by `MAI-Image-2.5-Flash` or `MAI-Image-2.5`.
MAI image constraints:
- Output: one PNG image per request.
- Size: default `1024x1024`; both width and height must be at least 768 px.
- Total pixels: width × height must be at most 1,048,576.
- Edits: one PNG or JPEG input image.
- Unsupported shared hints such as `aspectRatio`, `resolution`, `quality`,
`background`, and non-PNG `outputFormat` are not sent to Microsoft Foundry.
## Troubleshooting
- `az: command not found`: install the Azure CLI or use API-key auth.
- `Microsoft Foundry endpoint missing for MAI image generation`: select a
Foundry deployment through onboarding or add `models.providers.microsoft-foundry.baseUrl`.
- `supports MAI image deployments only`: the selected image model points at a
non-MAI deployment. Use a deployed MAI image model for `image_generate`.

View File

@@ -14,9 +14,8 @@ reference for **what to import** and **what you can register**.
<Note>
This page is for plugin authors using `openclaw/plugin-sdk/*` inside
OpenClaw. For external apps, scripts, dashboards, CI jobs, and IDE extensions
that want to run agents through the Gateway, use the
[OpenClaw App SDK](/concepts/openclaw-sdk) and the `@openclaw/sdk` package
instead.
that want to run agents through the Gateway, use
[Gateway integrations for external apps](/gateway/external-apps) instead.
</Note>
<Tip>

View File

@@ -101,21 +101,19 @@ explicit runtime config.
Control UI Talk with `talk.realtime.provider: "openai"`) goes through the
public **OpenAI Platform Realtime API**, which is billed against OpenAI
Platform credits rather than Codex/ChatGPT subscription quota. An account
with healthy OpenAI OAuth that runs Codex-backed chat models without
issue can still hit `insufficient_quota` / "You exceeded your current
quota" on the first Realtime turn if the same OpenAI organization has no
Platform billing set up.
with healthy OpenAI OAuth that runs Codex-backed chat models without issue
still needs an OpenAI API-key auth profile or a Platform API key with funded
Platform billing for Realtime voice.
Fix: top up Platform credits at
[platform.openai.com/account/billing](https://platform.openai.com/account/billing)
for the organization backing your realtime credentials. Realtime accepts
either a Platform `OPENAI_API_KEY` (configured via `talk.realtime.providers.openai.apiKey`
for Control UI Talk, or `plugins.entries.voice-call.config.realtime.providers.openai.apiKey`
for Voice Call) or an `openai` OAuth profile whose underlying
organization has Platform billing — both routes mint Realtime client secrets
through the Platform API, so either way the org needs funded Platform
credits. For chat turns you can still use Codex-backed `openai/*` models against the same
OpenClaw install; Realtime is the one route that needs Platform billing.
for the organization backing your realtime credentials. Realtime voice accepts
the `openai` API-key auth profile created by `openclaw onboard --auth-choice openai-api-key`,
a Platform `OPENAI_API_KEY` configured via `talk.realtime.providers.openai.apiKey`
for Control UI Talk, `plugins.entries.voice-call.config.realtime.providers.openai.apiKey`
for Voice Call, or the `OPENAI_API_KEY` environment variable. OpenAI OAuth
profiles can still run Codex-backed `openai/*` chat models in the same
OpenClaw install, but they do not configure Realtime voice.
</Note>
## Memory embeddings
@@ -646,7 +644,7 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
```
<Note>
Set `OPENAI_TTS_BASE_URL` to override the TTS base URL without affecting the chat API endpoint. OpenAI TTS is still configured through an API key; for OAuth-only live talk-back, use the Realtime voice path instead of agent-mode STT -> TTS speech.
Set `OPENAI_TTS_BASE_URL` to override the TTS base URL without affecting the chat API endpoint. OpenAI TTS and Realtime voice are both configured through an OpenAI Platform API key; OAuth-only installs can still use Codex-backed chat models, but not OpenAI live talk-back.
</Note>
</Accordion>
@@ -717,7 +715,7 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
| Silence duration | `...openai.silenceDurationMs` | `500` |
| Prefix padding | `...openai.prefixPaddingMs` | `300` |
| Reasoning effort | `...openai.reasoningEffort` | (unset) |
| Auth | `...openai.apiKey`, `OPENAI_API_KEY`, or `openai` OAuth | Browser Talk and non-Azure backend bridges can use OpenAI OAuth |
| Auth | `openai` API-key auth profile, `...openai.apiKey`, or `OPENAI_API_KEY` | OpenAI Platform API key required; OpenAI OAuth does not configure Realtime voice |
Available built-in Realtime voices for `gpt-realtime-2`: `alloy`, `ash`,
`ballad`, `coral`, `echo`, `sage`, `shimmer`, `verse`, `marin`, `cedar`.
@@ -739,10 +737,10 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
<Note>
Control UI Talk uses OpenAI browser realtime sessions with a Gateway-minted
ephemeral client secret and a direct browser WebRTC SDP exchange against the
OpenAI Realtime API. When no direct OpenAI API key is configured, the
Gateway can mint that client secret with the selected `openai` OAuth
profile. Gateway relay and Voice Call backend realtime WebSocket bridges use
the same OAuth fallback for native OpenAI endpoints. Maintainer live
OpenAI Realtime API. The Gateway mints that client secret with the selected
`openai` API-key auth profile or configured OpenAI Platform API key. Gateway
relay and Voice Call backend realtime WebSocket bridges use the same
API-key-only auth path for native OpenAI endpoints. Maintainer live
verification is available with
`OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts`;
the OpenAI legs verify both the backend WebSocket bridge and the browser

View File

@@ -209,13 +209,15 @@ vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. The helpe
OpenAI web search, and OpenWebUI
- `full`: Docker release-path chunks with OpenWebUI
- `custom`: exact `docker_lanes` selection for a focused rerun
- Run the manual `CI` workflow directly when you only need full normal CI
coverage for the release candidate. Manual CI dispatches bypass changed
- Run the manual `CI` workflow directly when you only need deterministic normal
CI coverage for the release candidate. Manual CI dispatches bypass changed
scoping and force the Linux Node shards, bundled-plugin shards, plugin and
channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`,
built-artifact smoke checks, docs checks, Python skills, Windows, macOS,
Android, and Control UI i18n lanes.
Example: `gh workflow run ci.yml --ref release/YYYY.M.PATCH`
built-artifact smoke checks, docs checks, Python skills, Windows, macOS, and
Control UI i18n lanes. Standalone manual CI runs Android only when dispatched
with `include_android=true`; `Full Release Validation` passes that input for
its CI child.
Example with Android: `gh workflow run ci.yml --ref release/YYYY.M.PATCH -f include_android=true`
- Run `pnpm qa:otel:smoke` when validating release telemetry. It exercises
QA-lab through a local OTLP/HTTP receiver and verifies trace, metric, and log
export plus bounded trace attributes and content/identifier redaction without
@@ -392,9 +394,10 @@ dispatches standalone package Telegram E2E when `release_profile=full` with
`npm_telegram_package_spec` is set. `OpenClaw Release
Checks` then fans out install smoke, cross-OS release checks, live/E2E Docker
release-path coverage when soak is enabled, Package Acceptance with Telegram
package QA, QA Lab parity, live Matrix, and live Telegram. A full run is only acceptable when the
`Full Release Validation`
summary shows `normal_ci` and `release_checks` as successful. In full/all mode,
package QA, QA Lab parity, live Matrix, and live Telegram. A full/all run is
only acceptable when the `Full Release Validation` summary shows `normal_ci`,
`plugin_prerelease`, and `release_checks` as successful, unless a focused rerun
intentionally skipped the separate `Plugin Prerelease` child. In full/all mode,
the `npm_telegram` child must also be successful; outside full/all it is skipped
unless a published `release_package_spec` or `npm_telegram_package_spec` was
provided. The final
@@ -501,7 +504,9 @@ bypasses changed scoping and forces the normal test graph for the release
candidate: Linux Node shards, bundled-plugin shards, plugin and channel contract
shards, Node 22 compatibility, `check-*`, `check-additional-*`,
built-artifact smoke checks, docs checks, Python skills, Windows, macOS,
Android, and Control UI i18n.
and Control UI i18n. Android is included when `Full Release Validation` runs the
box because the umbrella passes `include_android=true`; standalone manual CI
requires `include_android=true` for Android coverage.
Use this box to answer "did the source tree pass the full normal test suite?"
It is not the same as release-path product validation. Evidence to keep:
@@ -513,10 +518,13 @@ It is not the same as release-path product validation. Evidence to keep:
a run needs performance analysis
Run manual CI directly only when the release needs deterministic normal CI but
not the Docker, QA Lab, live, cross-OS, or package boxes:
not the Docker, QA Lab, live, cross-OS, or package boxes. Use the first command
for non-Android direct CI. Add `include_android=true` when direct
release-candidate CI must cover Android:
```bash
gh workflow run ci.yml --ref main -f target_ref=release/YYYY.M.PATCH
gh workflow run ci.yml --ref main -f target_ref=release/YYYY.M.PATCH -f include_android=true
```
### Docker

View File

@@ -274,13 +274,14 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
```
</Accordion>
<Accordion title="Local (GGUF + node-llama-cpp)">
<Accordion title="Local (GGUF + llama.cpp)">
| Key | Type | Default | Description |
| --------------------- | ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `local.modelPath` | `string` | auto-downloaded | Path to GGUF model file |
| `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models |
| `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128512 tokens) while bounding non-weight VRAM. Lower to 10242048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). |
Install the official llama.cpp provider first: `openclaw plugins install @openclaw/llama-cpp-provider`.
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Source checkouts still require native build approval: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
Use the standalone CLI to verify the same provider path the Gateway uses:

View File

@@ -1,390 +0,0 @@
---
summary: "Reference design for the public OpenClaw App SDK API, event taxonomy, artifacts, approvals, and package structure"
title: "OpenClaw App SDK API design"
sidebarTitle: "App SDK API design"
read_when:
- You are implementing the proposed public OpenClaw app SDK
- You need the draft namespace, event, result, artifact, approval, or security contract for the app SDK
- You are comparing Gateway protocol resources with the high-level OpenClaw App SDK wrapper
---
This page is the detailed API reference design for the public
[OpenClaw App SDK](/concepts/openclaw-sdk). It is intentionally separate from
the [Plugin SDK](/plugins/sdk-overview).
<Note>
`@openclaw/sdk` is the external app/client package for talking to the
Gateway. `openclaw/plugin-sdk/*` is the in-process plugin authoring contract.
Do not import Plugin SDK subpaths from apps that only need to run agents.
</Note>
The public app SDK should be built in two layers:
1. A low-level generated Gateway client.
2. A high-level ergonomic wrapper with `OpenClaw`, `Agent`, `Session`, `Run`,
`Task`, `Artifact`, `Approval`, and `Environment` objects.
## Namespace design
The low-level namespaces should closely follow Gateway resources:
```typescript
oc.agents.list();
oc.agents.get("main");
oc.agents.create(...);
oc.agents.update(...);
oc.sessions.list();
oc.sessions.create(...);
oc.sessions.resolve(...);
oc.sessions.send(...);
oc.sessions.messages(...);
oc.sessions.fork(...);
oc.sessions.compact(...);
oc.sessions.abort(...);
oc.runs.create(...);
oc.runs.get(runId);
oc.runs.events(runId, { after });
oc.runs.wait(runId);
oc.runs.cancel(runId);
oc.tasks.list({ status: "running" });
oc.tasks.get(taskId);
oc.tasks.cancel(taskId, { reason });
oc.tasks.events(taskId, { after }); // future API
oc.models.list();
oc.models.status(); // Gateway models.authStatus
oc.tools.list();
oc.tools.invoke("tool-name", { sessionKey, idempotencyKey });
oc.artifacts.list({ runId });
oc.artifacts.get(artifactId, { runId });
oc.artifacts.download(artifactId, { runId });
oc.approvals.list();
oc.approvals.respond(approvalId, ...);
oc.environments.list();
oc.environments.create(...); // future API: current SDK throws unsupported
oc.environments.status(environmentId);
oc.environments.delete(environmentId); // future API: current SDK throws unsupported
```
High-level wrappers should return objects that make common flows pleasant:
```typescript
const run = await agent.run(inputOrParams);
await run.cancel();
await run.wait();
for await (const event of run.events()) {
// normalized event stream
}
const artifacts = await run.artifacts.list();
const session = await run.session();
```
## Event contract
The public SDK should expose versioned, replayable, normalized events.
```typescript
type OpenClawEvent = {
version: 1;
id: string;
ts: number;
type: OpenClawEventType;
runId?: string;
sessionId?: string;
sessionKey?: string;
taskId?: string;
agentId?: string;
data: unknown;
raw?: unknown;
};
```
`id` is a replay cursor. Consumers should be able to reconnect with
`events({ after: id })` and receive missed events when retention allows.
Recommended normalized event families:
| Event | Meaning |
| --------------------- | ----------------------------------------------------------- |
| `run.created` | Run accepted. |
| `run.queued` | Run is waiting for a session lane, runtime, or environment. |
| `run.started` | Runtime started execution. |
| `run.completed` | Run finished successfully. |
| `run.failed` | Run ended with an error. |
| `run.cancelled` | Run was cancelled. |
| `run.timed_out` | Run exceeded its timeout. |
| `assistant.delta` | Assistant text delta. |
| `assistant.message` | Complete assistant message or replacement. |
| `thinking.delta` | Reasoning or plan delta, when policy allows exposure. |
| `tool.call.started` | Tool call began. |
| `tool.call.delta` | Tool call streamed progress or partial output. |
| `tool.call.completed` | Tool call returned successfully. |
| `tool.call.failed` | Tool call failed. |
| `approval.requested` | A run or tool needs approval. |
| `approval.resolved` | Approval was granted, denied, expired, or cancelled. |
| `question.requested` | Runtime asks the user or host app for input. |
| `question.answered` | Host app supplied an answer. |
| `artifact.created` | New artifact available. |
| `artifact.updated` | Existing artifact changed. |
| `session.created` | Session created. |
| `session.updated` | Session metadata changed. |
| `session.compacted` | Session compaction happened. |
| `task.updated` | Background task state changed. |
| `git.branch` | Runtime observed or changed branch state. |
| `git.diff` | Runtime produced or changed a diff. |
| `git.pr` | Runtime opened, updated, or linked a pull request. |
Runtime-native payloads should be available through `raw`, but apps should not
have to parse `raw` for normal UI.
## Result contract
`Run.wait()` should return a stable result envelope:
```typescript
type RunResult = {
runId: string;
status: "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
sessionId?: string;
sessionKey?: string;
taskId?: string;
startedAt?: string | number;
endedAt?: string | number;
output?: {
text?: string;
messages?: SDKMessage[];
};
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
costUsd?: number;
};
artifacts?: ArtifactSummary[];
error?: SDKError;
};
```
The result should be boring and stable. Timestamp values preserve the Gateway
shape, so current lifecycle-backed runs usually report epoch millisecond
numbers while adapters may still surface ISO strings. Rich UI, tool traces, and
runtime-native details belong in events and artifacts.
`accepted` is a non-terminal wait result: it means the Gateway wait deadline
expired before the run produced a lifecycle end/error. It must not be treated as
`timed_out`; `timed_out` is reserved for a run that exceeded its own runtime
timeout.
## Approvals and questions
Approvals must be first-class because coding agents constantly cross safety
boundaries.
```typescript
run.onApproval(async (request) => {
if (request.kind === "tool" && request.toolName === "exec") {
return request.approveOnce({ reason: "CI command allowed by policy" });
}
return request.askUser();
});
```
Approval events should carry:
- approval id
- run id and session id
- request kind
- requested action summary
- tool name or environment action
- risk level
- available decisions
- expiration
- whether the decision can be reused
Questions are separate from approvals. A question asks the user or host app for
information. An approval asks for permission to perform an action.
## ToolSpace model
Apps need to understand the tool surface without importing plugin internals.
```typescript
const tools = await run.toolSpace();
for (const tool of tools.list()) {
console.log(tool.name, tool.source, tool.requiresApproval);
}
```
The SDK should expose:
- normalized tool metadata
- source: OpenClaw, MCP, plugin, channel, runtime, or app
- schema summary
- approval policy
- runtime compatibility
- whether a tool is hidden, readonly, write capable, or host capable
Tool invocation through the SDK should be explicit and scoped. Most apps should
run agents, not call arbitrary tools directly.
## Artifact model
Artifacts should cover more than files.
```typescript
type ArtifactSummary = {
id: string;
runId?: string;
sessionId?: string;
type:
| "file"
| "patch"
| "diff"
| "log"
| "media"
| "screenshot"
| "trajectory"
| "pull_request"
| "workspace";
title?: string;
mimeType?: string;
sizeBytes?: number;
createdAt: string;
expiresAt?: string;
};
```
Common examples:
- file edits and generated files
- patch bundles
- VCS diffs
- screenshots and media outputs
- logs and trace bundles
- pull request links
- runtime trajectories
- managed environment workspace snapshots
Artifact access should support redaction, retention, and download URLs without
assuming every artifact is a normal local file.
## Security model
The app SDK must be explicit about authority.
Recommended token scopes:
| Scope | Allows |
| ------------------- | --------------------------------------------------- |
| `agent.read` | List and inspect agents. |
| `agent.run` | Start runs. |
| `session.read` | Read session metadata and messages. |
| `session.write` | Create, send to, fork, compact, and abort sessions. |
| `task.read` | Read background task state. |
| `task.write` | Cancel or modify task notification policy. |
| `approval.respond` | Approve or deny requests. |
| `tools.invoke` | Invoke exposed tools directly. |
| `artifacts.read` | List and download artifacts. |
| `environment.write` | Create or destroy managed environments. |
| `admin` | Administrative operations. |
Defaults:
- no secret forwarding by default
- no unrestricted environment variable pass-through
- secret references instead of secret values
- explicit sandbox and network policy
- explicit remote environment retention
- approvals for host execution unless policy proves otherwise
- raw runtime events redacted before they leave Gateway unless the caller has a
stronger diagnostic scope
## Managed environment provider
Managed agents should be implemented as environment providers.
```typescript
type EnvironmentProvider = {
id: string;
capabilities: {
checkout?: boolean;
sandbox?: boolean;
networkPolicy?: boolean;
secrets?: boolean;
artifacts?: boolean;
logs?: boolean;
pullRequests?: boolean;
longRunning?: boolean;
};
};
```
The first implementation does not need to be a hosted SaaS. It can target
existing node hosts, ephemeral workspaces, CI-style runners, or Testbox-style
environments. The important contract is:
1. prepare workspace
2. bind safe environment and secrets
3. start run
4. stream events
5. collect artifacts
6. clean up or retain by policy
Once this is stable, a hosted cloud service can implement the same provider
contract.
## Package structure
Recommended packages:
| Package | Purpose |
| ----------------------- | ------------------------------------------------------------- |
| `@openclaw/sdk` | Public high-level SDK and generated low-level Gateway client. |
| `@openclaw/sdk-react` | Optional React hooks for dashboards and app builders. |
| `@openclaw/sdk-testing` | Test helpers and fake Gateway server for app integrations. |
The repo already has `openclaw/plugin-sdk/*` for plugins. Keep that namespace
separate to avoid confusing plugin authors with app developers.
## Generated client strategy
The low-level client should be generated from versioned Gateway protocol
schemas, then wrapped by handwritten ergonomic classes.
Layering:
1. Gateway schema source of truth.
2. Generated low-level TypeScript client.
3. Runtime validators for external inputs and event payloads.
4. High-level `OpenClaw`, `Agent`, `Session`, `Run`, `Task`, and `Artifact`
wrappers.
5. Cookbook examples and integration tests.
Benefits:
- protocol drift is visible
- tests can compare generated methods with Gateway exports
- App SDK stays independent from Plugin SDK internals
- low-level consumers still have full protocol access
- high-level consumers get the small product API
## Related
- [OpenClaw App SDK](/concepts/openclaw-sdk)
- [Gateway RPC reference](/reference/rpc)
- [Agent loop](/concepts/agent-loop)
- [Agent runtimes](/concepts/agent-runtimes)
- [Background tasks](/automation/tasks)
- [ACP agents](/tools/acp-agents)
- [Plugin SDK overview](/plugins/sdk-overview)

View File

@@ -1,5 +1,5 @@
---
summary: "Generate and edit images via image_generate across OpenAI, Google, fal, MiniMax, ComfyUI, DeepInfra, OpenRouter, LiteLLM, xAI, Vydra"
summary: "Generate and edit images via image_generate across OpenAI, Google, fal, Microsoft Foundry, MiniMax, ComfyUI, DeepInfra, OpenRouter, LiteLLM, xAI, Vydra"
read_when:
- Generating or editing images via the agent
- Configuring image-generation providers and models
@@ -83,6 +83,7 @@ internal image endpoints remain blocked by default.
| fal Krea 2 expressive/style-directed generation | `fal/krea/v2/medium/text-to-image` | `FAL_KEY` |
| OpenRouter image generation | `openrouter/google/gemini-3.1-flash-image-preview` | `OPENROUTER_API_KEY` |
| LiteLLM image generation | `litellm/gpt-image-2` | `LITELLM_API_KEY` |
| Microsoft Foundry MAI image generation | `microsoft-foundry/<deployment-name>` | `AZURE_OPENAI_API_KEY` or Entra ID |
| Google Gemini image generation | `google/gemini-3.1-flash-image-preview` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
The same `image_generate` tool handles text-to-image and reference-image
@@ -97,18 +98,19 @@ backend emits it.
## Supported providers
| Provider | Default model | Edit support | Auth |
| ---------- | --------------------------------------- | ---------------------------------- | ----------------------------------------------------- |
| ComfyUI | `workflow` | Yes (1 image, workflow-configured) | `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY` for cloud |
| DeepInfra | `black-forest-labs/FLUX-1-schnell` | Yes (1 image) | `DEEPINFRA_API_KEY` |
| fal | `fal-ai/flux/dev` | Yes (model-specific limits) | `FAL_KEY` |
| Google | `gemini-3.1-flash-image-preview` | Yes | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
| LiteLLM | `gpt-image-2` | Yes (up to 5 input images) | `LITELLM_API_KEY` |
| MiniMax | `image-01` | Yes (subject reference) | `MINIMAX_API_KEY` or MiniMax OAuth (`minimax-portal`) |
| OpenAI | `gpt-image-2` | Yes (up to 4 images) | `OPENAI_API_KEY` or OpenAI ChatGPT/Codex OAuth |
| OpenRouter | `google/gemini-3.1-flash-image-preview` | Yes (up to 5 input images) | `OPENROUTER_API_KEY` |
| Vydra | `grok-imagine` | No | `VYDRA_API_KEY` |
| xAI | `grok-imagine-image` | Yes (up to 5 images) | `XAI_API_KEY` |
| Provider | Default model | Edit support | Auth |
| ----------------- | --------------------------------------- | ---------------------------------- | ----------------------------------------------------- |
| ComfyUI | `workflow` | Yes (1 image, workflow-configured) | `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY` for cloud |
| DeepInfra | `black-forest-labs/FLUX-1-schnell` | Yes (1 image) | `DEEPINFRA_API_KEY` |
| fal | `fal-ai/flux/dev` | Yes (model-specific limits) | `FAL_KEY` |
| Google | `gemini-3.1-flash-image-preview` | Yes | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
| LiteLLM | `gpt-image-2` | Yes (up to 5 input images) | `LITELLM_API_KEY` |
| Microsoft Foundry | `<deployment-name>` | Yes (MAI-Image-2.5 models only) | `AZURE_OPENAI_API_KEY` or Entra ID (`az login`) |
| MiniMax | `image-01` | Yes (subject reference) | `MINIMAX_API_KEY` or MiniMax OAuth (`minimax-portal`) |
| OpenAI | `gpt-image-2` | Yes (up to 4 images) | `OPENAI_API_KEY` or OpenAI ChatGPT/Codex OAuth |
| OpenRouter | `google/gemini-3.1-flash-image-preview` | Yes (up to 5 input images) | `OPENROUTER_API_KEY` |
| Vydra | `grok-imagine` | No | `VYDRA_API_KEY` |
| xAI | `grok-imagine-image` | Yes (up to 5 images) | `XAI_API_KEY` |
Use `action: "list"` to inspect available providers and models at runtime:
@@ -125,13 +127,13 @@ current session:
## Provider capabilities
| Capability | ComfyUI | DeepInfra | fal | Google | MiniMax | OpenAI | Vydra | xAI |
| --------------------- | ------------------ | --------- | ---------------------------------------------- | -------------- | --------------------- | -------------- | ----- | -------------- |
| Generate (max count) | Workflow-defined | 4 | 4 | 4 | 9 | 4 | 1 | 4 |
| Edit / reference | 1 image (workflow) | 1 image | Flux: 1; GPT: 10; Krea style refs: 10; NB2: 14 | Up to 5 images | 1 image (subject ref) | Up to 5 images | - | Up to 5 images |
| Size control | - | ✓ | ✓ | ✓ | - | Up to 4K | - | - |
| Aspect ratio | - | - | ✓ | ✓ | ✓ | - | - | ✓ |
| Resolution (1K/2K/4K) | - | - | ✓ | ✓ | - | - | - | 1K, 2K |
| Capability | ComfyUI | DeepInfra | fal | Google | Microsoft Foundry | MiniMax | OpenAI | Vydra | xAI |
| --------------------- | ------------------ | --------- | ---------------------------------------------- | -------------- | ----------------- | --------------------- | -------------- | ----- | -------------- |
| Generate (max count) | Workflow-defined | 4 | 4 | 4 | 1 | 9 | 4 | 1 | 4 |
| Edit / reference | 1 image (workflow) | 1 image | Flux: 1; GPT: 10; Krea style refs: 10; NB2: 14 | Up to 5 images | 1 image | 1 image (subject ref) | Up to 5 images | - | Up to 5 images |
| Size control | - | ✓ | ✓ | ✓ | ✓ | - | Up to 4K | - | - |
| Aspect ratio | - | - | ✓ | ✓ | - | ✓ | - | - | ✓ |
| Resolution (1K/2K/4K) | - | - | ✓ | ✓ | - | - | - | - | 1K, 2K |
## Tool parameters
@@ -249,10 +251,10 @@ from each attempt.
backends. A per-call `timeoutMs` tool parameter overrides the configured
default, and configured defaults override plugin-authored provider
defaults. Google and OpenRouter hosted image providers use 180 second
defaults; xAI and Azure OpenAI image generation use 600 seconds. Codex
dynamic-tool calls use a 120 second `image_generate` bridge default and
honor the same timeout budget when configured, bounded by OpenClaw's 600000
ms dynamic-tool bridge maximum.
defaults; Microsoft Foundry MAI, xAI, and Azure OpenAI image generation use
600 seconds. Codex dynamic-tool calls use a 120 second `image_generate`
bridge default and honor the same timeout budget when configured, bounded by
OpenClaw's 600000 ms dynamic-tool bridge maximum.
</Accordion>
<Accordion title="Inspect at runtime">
Use `action: "list"` to inspect the currently registered providers,
@@ -262,9 +264,10 @@ from each attempt.
### Image editing
OpenAI, OpenRouter, Google, DeepInfra, fal, MiniMax, ComfyUI, and xAI support editing
reference images. Krea 2 models on fal use the same `image` / `images` fields
as style references instead of edit inputs. Pass a reference image path or URL:
OpenAI, OpenRouter, Google, DeepInfra, fal, Microsoft Foundry, MiniMax,
ComfyUI, and xAI support editing reference images. Krea 2 models on fal use the
same `image` / `images` fields as style references instead of edit inputs. Pass
a reference image path or URL:
```text
"Generate a watercolor version of this photo" + image: "/path/to/photo.jpg"
@@ -273,7 +276,7 @@ as style references instead of edit inputs. Pass a reference image path or URL:
OpenAI, OpenRouter, Google, and xAI support up to 5 reference images via the
`images` parameter. fal supports 1 reference image for Flux image-to-image, up
to 10 for GPT Image 2 edits, up to 10 style references for Krea 2, and up to
14 for Nano Banana 2 edits. MiniMax and ComfyUI support 1.
14 for Nano Banana 2 edits. Microsoft Foundry, MiniMax, and ComfyUI support 1.
## Provider deep dives
@@ -334,6 +337,47 @@ to 10 for GPT Image 2 edits, up to 10 style references for Krea 2, and up to
instead of `api.openai.com`, see
[Azure OpenAI endpoints](/providers/openai#azure-openai-endpoints).
</Accordion>
<Accordion title="Microsoft Foundry MAI image models">
Microsoft Foundry image generation uses deployed MAI image deployment names
under the `microsoft-foundry/` provider prefix. There is no provider-level
default model because the MAI API expects your deployment name in the
`model` field:
```json5
{
agents: {
defaults: {
imageGenerationModel: {
primary: "microsoft-foundry/<deployment-name>",
timeoutMs: 600_000,
},
},
},
}
```
The provider uses Microsoft Foundry's MAI API, not the OpenAI Images API:
- Generation endpoint: `/mai/v1/images/generations`
- Edit endpoint: `/mai/v1/images/edits`
- Auth: `AZURE_OPENAI_API_KEY` / provider API key, or Entra ID through `az login`
- Output: one PNG image
- Size: default `1024x1024`; width and height must each be at least 768 px,
and total pixels must be at most 1,048,576
- Edits: one PNG or JPEG reference image, supported only by
`MAI-Image-2.5-Flash` and `MAI-Image-2.5` deployments
Prompt-only generation can use a custom deployment name with just the
Foundry endpoint configured. Edits with custom deployment names need
onboarding/model metadata so OpenClaw can verify that the deployment is
backed by `MAI-Image-2.5-Flash` or `MAI-Image-2.5`.
Current MAI image models are `MAI-Image-2.5-Flash`, `MAI-Image-2.5`,
`MAI-Image-2e`, and `MAI-Image-2`. See
[Microsoft Foundry plugin](/plugins/reference/microsoft-foundry) for setup
and chat-model behavior.
</Accordion>
<Accordion title="OpenRouter image models">
OpenRouter image generation uses the same `OPENROUTER_API_KEY` and
@@ -485,6 +529,7 @@ as ignored for them.
- [ComfyUI](/providers/comfy) - local ComfyUI and Comfy Cloud workflow setup
- [fal](/providers/fal) - fal image and video provider setup
- [Google (Gemini)](/providers/google) - Gemini image provider setup
- [Microsoft Foundry plugin](/plugins/reference/microsoft-foundry) - Microsoft Foundry chat and MAI image setup
- [MiniMax](/providers/minimax) - MiniMax image provider setup
- [OpenAI](/providers/openai) - OpenAI Images provider setup
- [Vydra](/providers/vydra) - Vydra image, video, and speech setup

View File

@@ -52,30 +52,31 @@ telephony, meetings, browser realtime, and native push-to-talk clients.
## Provider capability matrix
| Provider | Image | Video | Music | TTS | STT | Realtime voice | Media understanding |
| ----------- | :---: | :---: | :---: | :-: | :-: | :------------: | :-----------------: |
| Alibaba | | ✓ | | | | | |
| BytePlus | | ✓ | | | | | |
| ComfyUI | ✓ | ✓ | ✓ | | | | |
| DeepInfra | ✓ | ✓ | | ✓ | ✓ | | ✓ |
| Deepgram | | | | | ✓ | ✓ | |
| ElevenLabs | | | | ✓ | ✓ | | |
| fal | ✓ | ✓ | ✓ | | | | |
| Google | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
| Gradium | | | | ✓ | | | |
| Local CLI | | | | ✓ | | | |
| Microsoft | | | | ✓ | | | |
| MiniMax | ✓ | | | | | | |
| Mistral | | | | | ✓ | | |
| OpenAI | | | | ✓ | ✓ | | |
| OpenRouter | ✓ | ✓ | | ✓ | ✓ | | ✓ |
| Qwen | | ✓ | | | | | |
| Runway | | ✓ | | | | | |
| SenseAudio | | | | | | | |
| Together | | | | | | | |
| Vydra | | ✓ | | | | | |
| xAI | ✓ | ✓ | | ✓ | | | |
| Xiaomi MiMo | ✓ | | | ✓ | | | ✓ |
| Provider | Image | Video | Music | TTS | STT | Realtime voice | Media understanding |
| ----------------- | :---: | :---: | :---: | :-: | :-: | :------------: | :-----------------: |
| Alibaba | | ✓ | | | | | |
| BytePlus | | ✓ | | | | | |
| ComfyUI | ✓ | ✓ | ✓ | | | | |
| DeepInfra | ✓ | ✓ | | ✓ | ✓ | | ✓ |
| Deepgram | | | | | ✓ | ✓ | |
| ElevenLabs | | | | ✓ | ✓ | | |
| fal | ✓ | ✓ | ✓ | | | | |
| Google | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
| Gradium | | | | ✓ | | | |
| Local CLI | | | | ✓ | | | |
| Microsoft | | | | ✓ | | | |
| Microsoft Foundry | ✓ | | | | | | |
| MiniMax | | | | ✓ | | | |
| Mistral | | | | | ✓ | | |
| OpenAI | ✓ | ✓ | | ✓ | ✓ | | ✓ |
| OpenRouter | | ✓ | | | | | |
| Qwen | | ✓ | | | | | |
| Runway | | ✓ | | | | | |
| SenseAudio | | | | | | | |
| Together | | ✓ | | | | | |
| Vydra | ✓ | ✓ | | ✓ | | | |
| xAI | ✓ | | | ✓ | | | ✓ |
| Xiaomi MiMo | ✓ | | | ✓ | | | ✓ |
<Note>
Media understanding uses any vision-capable or audio-capable model registered

View File

@@ -201,7 +201,7 @@ Activity entries keep only sanitized summaries and redacted, truncated output pr
</Accordion>
<Accordion title="Talk mode (browser realtime)">
Talk mode uses a registered realtime voice provider. Configure OpenAI with `talk.realtime.provider: "openai"` plus either `talk.realtime.providers.openai.apiKey`, `OPENAI_API_KEY`, or an `openai` OAuth profile; configure Google with `talk.realtime.provider: "google"` plus `talk.realtime.providers.google.apiKey`. For hosted GPT realtime models, OpenClaw prefers the `openai` OAuth profile before `OPENAI_API_KEY`; an explicit OpenAI realtime `apiKey` remains the advanced override. The browser never receives a standard provider API key. OpenAI receives an ephemeral Realtime client secret for WebRTC. Google Live receives a one-use constrained Live API auth token for a browser WebSocket session, with instructions and tool declarations locked into the token by the Gateway. Providers that only expose a backend realtime bridge run through the Gateway relay transport, so credentials and vendor sockets stay server-side while browser audio moves through authenticated Gateway RPCs. The Realtime session prompt is assembled by the Gateway; `talk.client.create` does not accept caller-provided instruction overrides.
Talk mode uses a registered realtime voice provider. Configure OpenAI with `talk.realtime.provider: "openai"` plus an `openai` API-key auth profile, `talk.realtime.providers.openai.apiKey`, or `OPENAI_API_KEY`; OpenAI OAuth profiles do not configure Realtime voice. Configure Google with `talk.realtime.provider: "google"` plus `talk.realtime.providers.google.apiKey`. The browser never receives a standard provider API key. OpenAI receives an ephemeral Realtime client secret for WebRTC. Google Live receives a one-use constrained Live API auth token for a browser WebSocket session, with instructions and tool declarations locked into the token by the Gateway. Providers that only expose a backend realtime bridge run through the Gateway relay transport, so credentials and vendor sockets stay server-side while browser audio moves through authenticated Gateway RPCs. The Realtime session prompt is assembled by the Gateway; `talk.client.create` does not accept caller-provided instruction overrides.
The Chat composer includes a Talk options button next to the Talk start/stop button. The options apply to the next Talk session and can override provider, transport, model, voice, reasoning effort, VAD threshold, silence duration, and prefix padding. When an option is blank, the Gateway uses configured defaults where available or the provider default. Selecting Gateway relay forces the backend relay path; selecting WebRTC keeps the session client-owned and fails instead of silently falling back to relay if the provider cannot create a browser session.

View File

@@ -224,6 +224,56 @@ describe("AcpxRuntime fresh reset wrapper", () => {
});
});
it("strips the OpenClaw Anthropic provider prefix for Claude ACP startup", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) =>
agentName === "claude" ? "npx @agentclientprotocol/claude-agent-acp" : agentName,
list: () => ["claude", "openclaw"],
},
});
const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
sessionKey: "agent:claude:acp:test",
backend: "acpx",
runtimeSessionName: "claude",
});
await runtime.ensureSession({
sessionKey: "agent:claude:acp:test",
agent: "claude",
mode: "persistent",
model: "anthropic/claude-sonnet-4-6",
});
expect(readFirstEnsureSessionInput(ensure)).toEqual({
sessionKey: "agent:claude:acp:test",
agent: "claude",
mode: "persistent",
model: "claude-sonnet-4-6",
sessionOptions: { model: "claude-sonnet-4-6" },
});
});
it("keeps Claude ACP model ids intact after stripping the OpenClaw provider prefix", () => {
expect(testing.normalizeClaudeAcpModelOverride("anthropic/claude-sonnet-4-6")).toBe(
"claude-sonnet-4-6",
);
expect(testing.normalizeClaudeAcpModelOverride("anthropic/claude-opus-4-8")).toBe(
"claude-opus-4-8",
);
expect(testing.normalizeClaudeAcpModelOverride("anthropic/claude-haiku-4-5")).toBe(
"claude-haiku-4-5",
);
expect(testing.normalizeClaudeAcpModelOverride("anthropic/claude-sonnet-4-6-1m")).toBe(
"claude-sonnet-4-6-1m",
);
expect(testing.normalizeClaudeAcpModelOverride("custom-model")).toBe("custom-model");
});
it("leaves Codex ACP startup defaults alone when no model or thinking is provided", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
@@ -898,7 +948,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
expect(setConfigOption).not.toHaveBeenCalled();
});
it("still forwards non-timeout config controls for claude-agent-acp", async () => {
it("normalizes model config controls for claude-agent-acp", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => ({
acpxRecordId: "agent:claude:acp:test",
@@ -918,14 +968,14 @@ describe("AcpxRuntime fresh reset wrapper", () => {
await runtime.setConfigOption({
handle,
key: "model",
value: "claude-sonnet-4.6",
value: "anthropic/claude-sonnet-4-6",
});
expect(setConfigOption).toHaveBeenCalledOnce();
expect(setConfigOption).toHaveBeenCalledWith({
handle,
key: "model",
value: "claude-sonnet-4.6",
value: "claude-sonnet-4-6",
});
});

View File

@@ -329,6 +329,7 @@ const OPENCLAW_BRIDGE_EXECUTABLE = "openclaw";
const OPENCLAW_BRIDGE_SUBCOMMAND = "acp";
const CODEX_ACP_AGENT_ID = "codex";
const CODEX_ACP_OPENCLAW_PREFIX = "openai/";
const CLAUDE_ACP_OPENCLAW_PREFIX = "anthropic/";
const CODEX_ACP_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
const CODEX_ACP_THINKING_ALIASES = new Map<string, string | undefined>([
["off", undefined],
@@ -564,6 +565,17 @@ function codexAcpSessionModelId(override: CodexAcpModelOverride): string {
: override.model;
}
function normalizeClaudeAcpModelOverride(rawModel: string | undefined): string | undefined {
const raw = rawModel?.trim();
if (!raw) {
return undefined;
}
if (!raw.toLowerCase().startsWith(CLAUDE_ACP_OPENCLAW_PREFIX)) {
return raw;
}
return raw.slice(CLAUDE_ACP_OPENCLAW_PREFIX.length).trim() || undefined;
}
function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegateEnsureInput {
const existingOptions = (input as { sessionOptions?: SessionAgentOptions }).sessionOptions;
const model = input.model?.trim() || existingOptions?.model;
@@ -948,10 +960,14 @@ export class AcpxRuntime implements AcpRuntime {
agentRegistry: this.agentRegistry,
});
const delegate = this.resolveDelegateForCommand(command);
const claudeModelOverride = isClaudeAcpCommand(command)
? normalizeClaudeAcpModelOverride(input.model)
: undefined;
const codexModelOverride =
normalizeAgentName(input.agent) === CODEX_ACP_AGENT_ID && isCodexAcpCommand(command)
? normalizeCodexAcpModelOverride(input.model, input.thinking)
: undefined;
const ensureInput = claudeModelOverride ? { ...input, model: claudeModelOverride } : input;
const stableLaunchCommand =
codexModelOverride && command
? appendCodexAcpConfigOverrides(command, codexModelOverride)
@@ -966,20 +982,20 @@ export class AcpxRuntime implements AcpRuntime {
if (!codexModelOverride) {
return await this.runWithLaunchLease({
sessionKey: input.sessionKey,
sessionKey: ensureInput.sessionKey,
command: stableLaunchCommand,
enabled: shouldStartWithLease,
run: () =>
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => delegate.ensureSession(withAcpxSessionOptions(input)),
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
}),
});
}
const normalizedInput = {
...input,
...ensureInput,
...(codexAcpSessionModelId(codexModelOverride)
? { model: codexAcpSessionModelId(codexModelOverride) }
: {}),
@@ -1208,6 +1224,13 @@ export class AcpxRuntime implements AcpRuntime {
}
}
}
if (isClaudeAcpCommand(command) && key === "model") {
await delegate.setConfigOption({
...input,
value: normalizeClaudeAcpModelOverride(input.value) ?? input.value,
});
return;
}
await delegate.setConfigOption(input);
}
@@ -1260,6 +1283,7 @@ export const testing = {
codexAcpSessionModelId,
isClaudeAcpCommand,
isCodexAcpCommand,
normalizeClaudeAcpModelOverride,
normalizeCodexAcpModelOverride,
};

View File

@@ -8,16 +8,16 @@
"name": "@openclaw/codex",
"version": "2026.6.2",
"dependencies": {
"@openai/codex": "0.135.0",
"@openai/codex": "0.137.0",
"typebox": "1.1.39",
"ws": "8.21.0",
"zod": "4.4.3"
}
},
"node_modules/@openai/codex": {
"version": "0.135.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0.tgz",
"integrity": "sha512-ID75QEYmAT1WsUQmpxPlNsL5W1a+2eeD7fP6ywdwGseiXUG8D5i16L+dzbr8MT+2oTkaVqzOdvAqVOCeV/H/Bw==",
"version": "0.137.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0.tgz",
"integrity": "sha512-1jUsCnzDBwv7Z4VFZajIlsz41fC18qg6d5qK4PEZhiUk0zJHS90/uGBA70aQPUJLTUZShvyKVAANjw6J/D9eYQ==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
@@ -26,19 +26,19 @@
"node": ">=16"
},
"optionalDependencies": {
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.135.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.135.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.135.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.135.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.135.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.135.0-win32-x64"
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.137.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.137.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.137.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.137.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.137.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.137.0-win32-x64"
}
},
"node_modules/@openai/codex-darwin-arm64": {
"name": "@openai/codex",
"version": "0.135.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-darwin-arm64.tgz",
"integrity": "sha512-wpNzssusKfrldVlq39+HyQh1wCyc9SQNpHdAFGKtPenrgRte4Ct8/oVsDtKWuFZsqLBFwbL4MrzrevnB63+9HA==",
"version": "0.137.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-darwin-arm64.tgz",
"integrity": "sha512-YjKmre7DlKslQVhSfocHscgxntZKaZc1LQySKh7q+hNL8jdK+c8nSWSePi583yKFNIxZ8Z/zCkewtjFNvOpQiQ==",
"cpu": [
"arm64"
],
@@ -53,9 +53,9 @@
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
"version": "0.135.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-darwin-x64.tgz",
"integrity": "sha512-ZrjAqce23lbv9KfkYOhElf1lTI+SysXmyGM0FV5u4+PBCKPkkEs4eaS3H8Uig0i4bUSu1QylrOOCskzYhZ6VyQ==",
"version": "0.137.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-darwin-x64.tgz",
"integrity": "sha512-zjzrFV80LZby9et44dan82e3cwUd46U7u1LSVXTIz5AUcY4y1KZpAeN6cSLVKMZuOHXTDpi15MUQdRwzdeqIOg==",
"cpu": [
"x64"
],
@@ -70,9 +70,9 @@
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
"version": "0.135.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-linux-arm64.tgz",
"integrity": "sha512-dM+cv5ZL+BgIQzEIvMg9AxZ98n5lkKLgtp5zJLXWSrbCllbnUSqxYMUiWI5c1a1uBDUtkbY9fcGKXFLf+d+gyg==",
"version": "0.137.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-linux-arm64.tgz",
"integrity": "sha512-R3ZZymQQA1qpp6OpowN49XJ4scHwSckq7CjVvgmLv3bIs3X+F0XXK3xPFkC9vs2mX3wPekPi3ONpxx+yPAsJ6Q==",
"cpu": [
"arm64"
],
@@ -87,9 +87,9 @@
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
"version": "0.135.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-linux-x64.tgz",
"integrity": "sha512-5EosY67yU28UJSnl/obdN2F1CDaimYbzm9SLR8dwwzkeBBnY6dHgAKJ2GTu9Nc8CmgmtVFBGzgPqehsIcueVvA==",
"version": "0.137.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-linux-x64.tgz",
"integrity": "sha512-n+26MUj8rekbEDUeYTGoD6HXuGS0MmLHn2LOn0i5qTNYIJvXV82B7cCLSTzVKF/RJxRMRl22se9Q0Z035JIVng==",
"cpu": [
"x64"
],
@@ -104,9 +104,9 @@
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
"version": "0.135.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-win32-arm64.tgz",
"integrity": "sha512-SAeR+CUv7KWwE6eTc2UFaFjo6FpHywYfDFKrK6FqLms1rq1NPju2SoX7rhM6UEew/lUx2mdZv/LDs11s/N/Qgg==",
"version": "0.137.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-win32-arm64.tgz",
"integrity": "sha512-Cofktt213TycdQ/v+nAUuwXUBzjMWfA/ZkXyqefyXxDgw0TMtaiM3cgDna3I8YdXnR0PM9AMbx4t7VloJ3ZZYQ==",
"cpu": [
"arm64"
],
@@ -121,9 +121,9 @@
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
"version": "0.135.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-win32-x64.tgz",
"integrity": "sha512-uYwUBMbOfmVlCESJZmZsOG+cYwNFYvkMbQ+FB6C1u9RYz0m3mZeYNN0j+l1hRSyUgPMFJHzNpgNx1Usal5QZFQ==",
"version": "0.137.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-win32-x64.tgz",
"integrity": "sha512-g9qZ9ERrm5OWXMWJOgojYv1kOc5jajTKq37PBMSe56aJfAr9Jk/qBvIOy7LKq3rABdXuz8k+W65PIt2E1hXilw==",
"cpu": [
"x64"
],

View File

@@ -8,7 +8,7 @@
},
"type": "module",
"dependencies": {
"@openai/codex": "0.135.0",
"@openai/codex": "0.137.0",
"typebox": "1.1.39",
"ws": "8.21.0",
"zod": "4.4.3"

View File

@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import {
buildCodexNativeHookRelayConfig,
buildCodexNativeHookRelayDisabledConfig,
resolveCodexNativeHookRelayCommandTimeoutMs,
resolveCodexNativeHookRelayUnregisterGraceMs,
} from "./native-hook-relay.js";
@@ -23,7 +24,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --timeout 6000",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -37,7 +38,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event post_tool_use",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event post_tool_use --timeout 6000",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -51,7 +52,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request --timeout 6000",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -65,7 +66,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event before_agent_finalize",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event before_agent_finalize --timeout 6000",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -128,7 +129,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request --timeout 4000",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -163,7 +164,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --timeout 4000",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -200,7 +201,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --pre-tool-use-unavailable noop",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --pre-tool-use-unavailable noop --timeout 4000",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -238,7 +239,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request --timeout 4000",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -266,6 +267,12 @@ describe("Codex native hook relay config", () => {
});
});
it("reserves relay timeout margin before Codex can kill the hook subprocess", () => {
expect(resolveCodexNativeHookRelayCommandTimeoutMs(undefined)).toBe(4000);
expect(resolveCodexNativeHookRelayCommandTimeoutMs(1)).toBe(750);
expect(resolveCodexNativeHookRelayCommandTimeoutMs(7)).toBe(6000);
});
it("omits matchers so Codex MCP tool names reach the relay with a stable trust hash", () => {
const config = buildCodexNativeHookRelayConfig({
relay: createRelay(),
@@ -311,12 +318,12 @@ function createRelay(options?: {
allowedEvents: ["pre_tool_use", "post_tool_use", "permission_request", "before_agent_finalize"],
expiresAtMs: Date.now() + 1000,
shouldRelayEvent: (event) => !inactiveEvents.has(event),
commandForEvent: (event) =>
commandForEvent: (event, commandOptions) =>
`openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event ${event}${
event === "pre_tool_use" && inactiveEvents.has(event)
? " --pre-tool-use-unavailable noop"
: ""
}`,
}${commandOptions?.timeoutMs ? ` --timeout ${commandOptions.timeoutMs}` : ""}`,
renew: () => undefined,
unregister: () => undefined,
};

View File

@@ -29,6 +29,8 @@ const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS =
const CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS = 30 * 60_000;
/** Extra relay lifetime after the expected turn budget, preventing late hook drops. */
export const CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS = 5 * 60_000;
const CODEX_NATIVE_HOOK_RELAY_COMMAND_MIN_PARENT_MARGIN_MS = 250;
const CODEX_NATIVE_HOOK_RELAY_COMMAND_MAX_PARENT_MARGIN_MS = 1_000;
const CODEX_NATIVE_HOOK_RELAY_UNREGISTER_GRACE_MS = 10_000;
const CODEX_NATIVE_HOOK_RELAY_UNREGISTER_EXTRA_GRACE_MS = 5_000;
@@ -263,8 +265,10 @@ export function buildCodexNativeHookRelayConfig(params: {
}
continue;
}
const command = params.relay.commandForEvent(event);
const timeout = normalizeHookTimeoutSec(params.hookTimeoutSec);
const command = params.relay.commandForEvent(event, {
timeoutMs: resolveCodexNativeHookRelayCommandTimeoutMs(timeout),
});
config[`hooks.${codexEvent}`] = [
{
hooks: [
@@ -311,6 +315,18 @@ function normalizeHookTimeoutSec(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.ceil(value) : 5;
}
export function resolveCodexNativeHookRelayCommandTimeoutMs(
hookTimeoutSec: number | undefined,
): number {
const parentTimeoutMs =
finiteSecondsToTimerSafeMilliseconds(normalizeHookTimeoutSec(hookTimeoutSec)) ?? 5_000;
const parentMarginMs = Math.min(
CODEX_NATIVE_HOOK_RELAY_COMMAND_MAX_PARENT_MARGIN_MS,
Math.max(CODEX_NATIVE_HOOK_RELAY_COMMAND_MIN_PARENT_MARGIN_MS, Math.floor(parentTimeoutMs / 5)),
);
return Math.max(1, parentTimeoutMs - parentMarginMs);
}
function codexCommandHookTrustedHash(params: {
event: NativeHookRelayEvent;
command: string;

View File

@@ -380,7 +380,7 @@ describe("CodexNativeSubagentMonitor", () => {
const runtime = createRuntime();
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
codexHome,
transcriptPollDelaysMs: [10],
transcriptPollDelaysMs: [10, 1],
});
monitor.registerParent({
parentThreadId: "parent-thread",
@@ -400,15 +400,20 @@ describe("CodexNativeSubagentMonitor", () => {
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(20);
await vi.advanceTimersByTimeAsync(10);
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({
childSessionId: "child-thread",
status: "succeeded",
statusLabel: "completed_without_final_message",
result: "Codex native subagent completed without a final assistant message.",
}),
await vi.advanceTimersByTimeAsync(1);
await vi.waitFor(() =>
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({
childSessionId: "child-thread",
status: "succeeded",
statusLabel: "completed_without_final_message",
result: "Codex native subagent completed without a final assistant message.",
}),
),
);
client.close();

View File

@@ -606,23 +606,31 @@ export class CodexNativeSubagentMonitor {
const delayMs = noFinalCompletionFallbackDelayMs(this.transcriptPollDelaysMs);
childState.noFinalCompletionFallbackTimer = setTimeout(() => {
childState.noFinalCompletionFallbackTimer = undefined;
void this.reconcileChildTranscript(childState.childThreadId)
.catch((error: unknown) => {
embeddedAgentLog.warn("Failed to reconcile Codex native subagent transcript", {
childThreadId: childState.childThreadId,
error: formatErrorMessage(error),
});
return false;
})
.then((reconciled) => {
if (!reconciled && !childState.transcriptTerminal) {
void this.processCompletion(state, completion, eventAt);
}
});
void this.deliverNoFinalCompletionFallback(state, childState, completion, eventAt);
}, delayMs);
unrefTimer(childState.noFinalCompletionFallbackTimer);
}
private async deliverNoFinalCompletionFallback(
state: ParentState,
childState: ChildState,
completion: CodexNativeSubagentCompletion,
eventAt: number,
): Promise<void> {
const reconciled = await this.reconcileChildTranscript(childState.childThreadId).catch(
(error: unknown): false => {
embeddedAgentLog.warn("Failed to reconcile Codex native subagent transcript", {
childThreadId: childState.childThreadId,
error: formatErrorMessage(error),
});
return false;
},
);
if (!reconciled && !childState.transcriptTerminal) {
await this.processCompletion(state, completion, eventAt);
}
}
private clearTimers(): void {
if (this.taskRowReconcileTimer) {
clearInterval(this.taskRowReconcileTimer);

View File

@@ -1,14 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DynamicToolCallParams",
"type": "object",
"required": [
"arguments",
"callId",
"threadId",
"tool",
"turnId"
],
"properties": {
"arguments": true,
"callId": {
@@ -29,5 +20,14 @@
"turnId": {
"type": "string"
}
}
},
"required": [
"arguments",
"callId",
"threadId",
"tool",
"turnId"
],
"title": "DynamicToolCallParams",
"type": "object"
}

View File

@@ -1,33 +1,127 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ErrorNotification",
"type": "object",
"required": [
"error",
"threadId",
"turnId",
"willRetry"
],
"properties": {
"error": {
"$ref": "#/definitions/TurnError"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
},
"willRetry": {
"type": "boolean"
}
},
"definitions": {
"CodexErrorInfo": {
"description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.",
"oneOf": [
{
"type": "string",
"additionalProperties": false,
"properties": {
"httpConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"httpConnectionFailed"
],
"title": "HttpConnectionFailedCodexErrorInfo",
"type": "object"
},
{
"additionalProperties": false,
"description": "Failed to connect to the response SSE stream.",
"properties": {
"responseStreamConnectionFailed": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamConnectionFailed"
],
"title": "ResponseStreamConnectionFailedCodexErrorInfo",
"type": "object"
},
{
"additionalProperties": false,
"description": "The response SSE stream disconnected in the middle of a turn before completion.",
"properties": {
"responseStreamDisconnected": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseStreamDisconnected"
],
"title": "ResponseStreamDisconnectedCodexErrorInfo",
"type": "object"
},
{
"additionalProperties": false,
"description": "Reached the retry limit for responses.",
"properties": {
"responseTooManyFailedAttempts": {
"properties": {
"httpStatusCode": {
"format": "uint16",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"required": [
"responseTooManyFailedAttempts"
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
},
{
"additionalProperties": false,
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
"properties": {
"activeTurnNotSteerable": {
"properties": {
"turnKind": {
"$ref": "#/definitions/NonSteerableTurnKind"
}
},
"required": [
"turnKind"
],
"type": "object"
}
},
"required": [
"activeTurnNotSteerable"
],
"title": "ActiveTurnNotSteerableCodexErrorInfo",
"type": "object"
},
{
"enum": [
"contextWindowExceeded",
"usageLimitExceeded",
@@ -39,139 +133,19 @@
"threadRollbackFailed",
"sandboxError",
"other"
]
},
{
"type": "object",
"required": [
"httpConnectionFailed"
],
"properties": {
"httpConnectionFailed": {
"type": "object",
"properties": {
"httpStatusCode": {
"type": [
"integer",
"null"
],
"format": "uint16",
"minimum": 0
}
}
}
},
"additionalProperties": false,
"title": "HttpConnectionFailedCodexErrorInfo"
},
{
"description": "Failed to connect to the response SSE stream.",
"type": "object",
"required": [
"responseStreamConnectionFailed"
],
"properties": {
"responseStreamConnectionFailed": {
"type": "object",
"properties": {
"httpStatusCode": {
"type": [
"integer",
"null"
],
"format": "uint16",
"minimum": 0
}
}
}
},
"additionalProperties": false,
"title": "ResponseStreamConnectionFailedCodexErrorInfo"
},
{
"description": "The response SSE stream disconnected in the middle of a turn before completion.",
"type": "object",
"required": [
"responseStreamDisconnected"
],
"properties": {
"responseStreamDisconnected": {
"type": "object",
"properties": {
"httpStatusCode": {
"type": [
"integer",
"null"
],
"format": "uint16",
"minimum": 0
}
}
}
},
"additionalProperties": false,
"title": "ResponseStreamDisconnectedCodexErrorInfo"
},
{
"description": "Reached the retry limit for responses.",
"type": "object",
"required": [
"responseTooManyFailedAttempts"
],
"properties": {
"responseTooManyFailedAttempts": {
"type": "object",
"properties": {
"httpStatusCode": {
"type": [
"integer",
"null"
],
"format": "uint16",
"minimum": 0
}
}
}
},
"additionalProperties": false,
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo"
},
{
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
"type": "object",
"required": [
"activeTurnNotSteerable"
],
"properties": {
"activeTurnNotSteerable": {
"type": "object",
"required": [
"turnKind"
],
"properties": {
"turnKind": {
"$ref": "#/definitions/NonSteerableTurnKind"
}
}
}
},
"additionalProperties": false,
"title": "ActiveTurnNotSteerableCodexErrorInfo"
"type": "string"
}
]
},
"NonSteerableTurnKind": {
"type": "string",
"enum": [
"review",
"compact"
]
],
"type": "string"
},
"TurnError": {
"type": "object",
"required": [
"message"
],
"properties": {
"additionalDetails": {
"default": null,
@@ -193,7 +167,33 @@
"message": {
"type": "string"
}
}
},
"required": [
"message"
],
"type": "object"
}
}
},
"properties": {
"error": {
"$ref": "#/definitions/TurnError"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
},
"willRetry": {
"type": "boolean"
}
},
"required": [
"error",
"threadId",
"turnId",
"willRetry"
],
"title": "ErrorNotification",
"type": "object"
}

View File

@@ -1,10 +1,84 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "GetAccountResponse",
"type": "object",
"required": [
"requiresOpenaiAuth"
],
"definitions": {
"Account": {
"oneOf": [
{
"properties": {
"type": {
"enum": [
"apiKey"
],
"title": "ApiKeyAccountType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ApiKeyAccount",
"type": "object"
},
{
"properties": {
"email": {
"type": "string"
},
"planType": {
"$ref": "#/definitions/PlanType"
},
"type": {
"enum": [
"chatgpt"
],
"title": "ChatgptAccountType",
"type": "string"
}
},
"required": [
"email",
"planType",
"type"
],
"title": "ChatgptAccount",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockAccountType",
"type": "string"
}
},
"required": [
"type"
],
"title": "AmazonBedrockAccount",
"type": "object"
}
]
},
"PlanType": {
"enum": [
"free",
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
],
"type": "string"
}
},
"properties": {
"account": {
"anyOf": [
@@ -20,83 +94,9 @@
"type": "boolean"
}
},
"definitions": {
"Account": {
"oneOf": [
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"apiKey"
],
"title": "ApiKeyAccountType"
}
},
"title": "ApiKeyAccount"
},
{
"type": "object",
"required": [
"email",
"planType",
"type"
],
"properties": {
"email": {
"type": "string"
},
"planType": {
"$ref": "#/definitions/PlanType"
},
"type": {
"type": "string",
"enum": [
"chatgpt"
],
"title": "ChatgptAccountType"
}
},
"title": "ChatgptAccount"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockAccountType"
}
},
"title": "AmazonBedrockAccount"
}
]
},
"PlanType": {
"type": "string",
"enum": [
"free",
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
]
}
}
"required": [
"requiresOpenaiAuth"
],
"title": "GetAccountResponse",
"type": "object"
}

View File

@@ -1,65 +1,34 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ModelListResponse",
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/Model"
}
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.",
"type": [
"string",
"null"
]
}
},
"definitions": {
"InputModality": {
"description": "Canonical user-input modality tags advertised by a model.",
"oneOf": [
{
"description": "Plain text turns and tool payloads.",
"type": "string",
"enum": [
"text"
]
],
"type": "string"
},
{
"description": "Image attachments included in user turns.",
"type": "string",
"enum": [
"image"
]
],
"type": "string"
}
]
},
"Model": {
"type": "object",
"required": [
"defaultReasoningEffort",
"description",
"displayName",
"hidden",
"id",
"isDefault",
"model",
"supportedReasoningEfforts"
],
"properties": {
"additionalSpeedTiers": {
"description": "Deprecated: use `serviceTiers` instead.",
"default": [],
"type": "array",
"description": "Deprecated: use `serviceTiers` instead.",
"items": {
"type": "string"
}
},
"type": "array"
},
"availabilityNux": {
"anyOf": [
@@ -74,6 +43,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -91,10 +68,10 @@
"text",
"image"
],
"type": "array",
"items": {
"$ref": "#/definitions/InputModality"
}
},
"type": "array"
},
"isDefault": {
"type": "boolean"
@@ -104,16 +81,16 @@
},
"serviceTiers": {
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/ModelServiceTier"
}
},
"type": "array"
},
"supportedReasoningEfforts": {
"type": "array",
"items": {
"$ref": "#/definitions/ReasoningEffortOption"
}
},
"type": "array"
},
"supportsPersonality": {
"default": false,
@@ -135,26 +112,31 @@
}
]
}
}
},
"required": [
"defaultReasoningEffort",
"description",
"displayName",
"hidden",
"id",
"isDefault",
"model",
"supportedReasoningEfforts"
],
"type": "object"
},
"ModelAvailabilityNux": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
},
"required": [
"message"
],
"type": "object"
},
"ModelServiceTier": {
"type": "object",
"required": [
"description",
"id",
"name"
],
"properties": {
"description": {
"type": "string"
@@ -165,13 +147,15 @@
"name": {
"type": "string"
}
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"ModelUpgradeInfo": {
"type": "object",
"required": [
"model"
],
"properties": {
"migrationMarkdown": {
"type": [
@@ -194,11 +178,14 @@
"null"
]
}
}
},
"required": [
"model"
],
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"type": "string",
"enum": [
"none",
"minimal",
@@ -206,14 +193,10 @@
"medium",
"high",
"xhigh"
]
],
"type": "string"
},
"ReasoningEffortOption": {
"type": "object",
"required": [
"description",
"reasoningEffort"
],
"properties": {
"description": {
"type": "string"
@@ -221,7 +204,32 @@
"reasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
}
}
},
"required": [
"description",
"reasoningEffort"
],
"type": "object"
}
}
},
"properties": {
"data": {
"items": {
"$ref": "#/definitions/Model"
},
"type": "array"
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.",
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ModelListResponse",
"type": "object"
}

View File

@@ -90,6 +90,7 @@ export type CodexThreadStartParams = JsonObject & {
developerInstructions?: string;
experimentalRawEvents?: boolean;
environments?: CodexTurnEnvironmentParams[] | null;
/** Retired by Codex 0.137, but still sent for supported custom app-server 0.125-0.136. */
persistExtendedHistory?: boolean;
};
@@ -104,6 +105,7 @@ export type CodexThreadResumeParams = JsonObject & {
serviceTier?: CodexServiceTier | null;
config?: JsonObject;
developerInstructions?: string;
/** Retired by Codex 0.137, but still sent for supported custom app-server 0.125-0.136. */
persistExtendedHistory?: boolean;
};

View File

@@ -9,4 +9,4 @@ export const MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION = "0.132.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
// Keep this in sync with the Codex CLI live-test package pin.
/** Managed Codex app-server package version installed by OpenClaw. */
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.135.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.137.0";

View File

@@ -7,7 +7,11 @@ import {
createEmptyPluginRegistry,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/session-store-runtime";
import {
clearSessionStoreCacheForTest,
saveSessionStore,
type SessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType, type AutocompleteInteraction } from "../internal/discord.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
@@ -130,6 +134,25 @@ let findCommandByNativeName: typeof import("openclaw/plugin-sdk/command-auth-nat
let resolveCommandArgChoices: typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgChoices;
let resolveDiscordNativeChoiceContext: typeof import("./native-command-model-picker-ui.js").resolveDiscordNativeChoiceContext;
async function saveSessionOverride(params: {
providerOverride: string;
modelOverride: string;
}): Promise<void> {
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
await saveSessionStore(
STORE_PATH,
{
[SESSION_KEY]: {
sessionId: "main",
updatedAt: Date.now(),
providerOverride: params.providerOverride,
modelOverride: params.modelOverride,
},
} satisfies Record<string, SessionEntry>,
{ skipMaintenance: true },
);
}
function installProviderThinkingRegistryForTest(): void {
const registry = createEmptyPluginRegistry();
registry.providers.push({
@@ -199,7 +222,7 @@ describe("discord native /think autocomplete", () => {
await loadDiscordThinkAutocompleteModulesForTest());
});
beforeEach(() => {
beforeEach(async () => {
clearSessionStoreCacheForTest();
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
@@ -218,18 +241,10 @@ describe("discord native /think autocomplete", () => {
: undefined,
);
installProviderThinkingRegistryForTest();
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
fs.writeFileSync(
STORE_PATH,
JSON.stringify({
[SESSION_KEY]: {
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "gpt-5.4",
},
}),
"utf8",
);
await saveSessionOverride({
providerOverride: "openai",
modelOverride: "gpt-5.4",
});
});
afterEach(() => {
@@ -318,17 +333,10 @@ describe("discord native /think autocomplete", () => {
? { levels: [{ id: "off" }, { id: "max" }] }
: undefined,
);
fs.writeFileSync(
STORE_PATH,
JSON.stringify({
[SESSION_KEY]: {
updatedAt: Date.now(),
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
},
}),
"utf8",
);
await saveSessionOverride({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
});
const cfg = createConfig();
resolveConfiguredBindingRouteMock.mockImplementation(createConfiguredRouteResult);
const interaction = {

View File

@@ -89,19 +89,109 @@ export function createFeishuApiError(
return new Error(formatFeishuApiFailure(error, errorPrefix, options), { cause: error });
}
// Feishu message-API error codes that signal a transient rate limit; safe to retry with backoff.
// 230020: per-chat rate limit (ext=chat rate limit) — confirmed by real concurrent load test.
// 11232: tenant-level "create message service trigger rate limit" (100/min, 5/sec per app/bot).
// Distinct from FEISHU_BACKOFF_CODES in typing.ts, which covers the reaction API (99991400+).
const FEISHU_SEND_RATE_LIMIT_CODES = new Set([230020, 11232]);
const FEISHU_SEND_MAX_RETRIES = 2;
const FEISHU_SEND_RETRY_BASE_MS = 500;
/**
* Returns a numeric rate-limit signal when an AxiosError indicates a retryable
* Feishu message-API rate limit. Sources, in priority order:
* 1. Gateway-level HTTP 429 (app-wide quota; `x-ogw-ratelimit-reset` header)
* 2. Business-level `code` in `error.response.data.code` matching
* FEISHU_SEND_RATE_LIMIT_CODES (e.g. 230020 per-chat, 11232 tenant-level).
* Returns `undefined` for all other errors so they propagate without retry.
*/
export function getFeishuSendRateLimitCode(error: unknown): number | undefined {
if (!isRecord(error)) {
return undefined;
}
const response = isRecord(error.response) ? error.response : undefined;
// HTTP 429: Feishu Open API gateway-level rate limit, always retry.
if (typeof response?.status === "number" && response.status === 429) {
return 429;
}
const data = isRecord(response?.data) ? response.data : undefined;
const code = data?.code;
return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined;
}
/**
* Returns a retryable rate-limit code when a fulfilled (non-throwing) Feishu
* SDK response embeds it in the response body. The Feishu node SDK can resolve
* with `{ code: 11232, msg: "..." }` instead of throwing — see typing.ts
* (getBackoffCodeFromResponse) and issue #28157 for the same behavior on
* messageReaction.create. Without this classification, requestFeishuApi would
* `return` the rate-limited body and downstream `assertFeishuMessageApiSuccess`
* would fail once with no retry.
*/
export function getFeishuSendRateLimitCodeFromResponse(response: unknown): number | undefined {
if (!isRecord(response)) {
return undefined;
}
const code = (response as { code?: unknown }).code;
return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined;
}
export async function requestFeishuApi<T>(
request: () => Promise<T>,
errorPrefix: string,
options: {
includeConfigParams?: boolean;
includeNestedErrorLogId?: boolean;
/** Base delay per retry attempt in ms; multiplied by attempt index. @internal */
retryDelayMs?: number;
} = {},
): Promise<T> {
try {
return await request();
} catch (error) {
throw createFeishuApiError(error, errorPrefix, options);
const retryDelayMs = options.retryDelayMs ?? FEISHU_SEND_RETRY_BASE_MS;
let lastFulfilledRateLimit: { response: unknown; code: number } | undefined;
for (let attempt = 0; attempt <= FEISHU_SEND_MAX_RETRIES; attempt++) {
if (attempt > 0) {
// Linear backoff: delay grows with each attempt to give the rate-limit window time to reset.
await new Promise<void>((resolve) => {
setTimeout(resolve, attempt * retryDelayMs);
});
}
try {
const result = await request();
// Feishu SDK may fulfill with a rate-limit body (e.g. { code: 11232, ... })
// instead of throwing. Classify before returning so retry covers both shapes.
const fulfilledRateLimit = getFeishuSendRateLimitCodeFromResponse(result);
if (fulfilledRateLimit !== undefined) {
// Capture for the synthetic-error path below; on a non-final attempt
// continue retrying, on the final attempt fall through so the loop
// exits and the wrapped exhaustion error is thrown.
lastFulfilledRateLimit = { response: result, code: fulfilledRateLimit };
if (attempt < FEISHU_SEND_MAX_RETRIES) {
continue;
}
break;
}
return result;
} catch (error) {
const isRetryable =
attempt < FEISHU_SEND_MAX_RETRIES && getFeishuSendRateLimitCode(error) !== undefined;
if (!isRetryable) {
throw createFeishuApiError(error, errorPrefix, options);
}
// Rate-limit on a non-final attempt — loop continues to next retry.
}
}
// Exhausted retries while the SDK kept fulfilling rate-limit bodies. Surface
// the last response as an error so callers see the same wrapped shape they
// would have seen if the SDK had thrown.
if (lastFulfilledRateLimit) {
const synthetic = Object.assign(
new Error(`Request fulfilled with rate-limit code ${lastFulfilledRateLimit.code}`),
{ response: { status: 200, data: lastFulfilledRateLimit.response } },
);
throw createFeishuApiError(synthetic, errorPrefix, options);
}
// Unreachable: every iteration either returns or throws. Required for TypeScript exhaustiveness.
throw createFeishuApiError(new Error("unreachable"), errorPrefix, options);
}
type ParsedCommentDocumentRef = {

View File

@@ -0,0 +1,288 @@
/**
* Concurrent Feishu message send stress tests.
*
* Verifies that sendMessageFeishu behaves correctly under concurrent load,
* including the rate-limit error code (230020) the Feishu API returns when
* the per-chat request frequency is too high. Related: issue #70879.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
const {
mockClientCreate,
mockCreateFeishuClient,
mockResolveFeishuAccount,
mockConvertMarkdownTables,
mockResolveMarkdownTableMode,
} = vi.hoisted(() => ({
mockClientCreate: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
mockConvertMarkdownTables: vi.fn((text: string) => text),
mockResolveMarkdownTableMode: vi.fn(() => "preserve"),
}));
vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient }));
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: mockResolveFeishuAccount,
resolveFeishuRuntimeAccount: mockResolveFeishuAccount,
}));
vi.mock("openclaw/plugin-sdk/markdown-table-runtime", () => ({
resolveMarkdownTableMode: mockResolveMarkdownTableMode,
}));
vi.mock("openclaw/plugin-sdk/text-chunking", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-chunking")>();
return { ...actual, convertMarkdownTables: mockConvertMarkdownTables };
});
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
channel: {
text: {
resolveMarkdownTableMode: vi.fn(() => "preserve"),
convertMarkdownTables: vi.fn((text: string) => text),
},
},
}),
}));
let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu;
const MOCK_CFG = {} as ClawdbotConfig;
/** Build a successful send response. */
function okResponse(messageId: string) {
return { code: 0, data: { message_id: messageId } };
}
/**
* Build an AxiosError-shaped object for a Feishu rate-limit HTTP 400 response.
* Mirrors what @larksuiteoapi/node-sdk throws when the server returns code 230020.
*/
function axiosRateLimitError(code = 230020) {
return Object.assign(new Error("Request failed with status code 400"), {
response: {
status: 400,
data: {
code,
msg: "This operation triggers the frequency limit, ext=chat rate limit",
},
},
});
}
beforeAll(async () => {
({ sendMessageFeishu } = await import("./send.js"));
});
afterAll(() => {
vi.resetModules();
});
beforeEach(() => {
vi.clearAllMocks();
mockResolveFeishuAccount.mockReturnValue({ accountId: "default", configured: true });
mockResolveMarkdownTableMode.mockReturnValue("preserve");
mockConvertMarkdownTables.mockImplementation((text: string) => text);
mockCreateFeishuClient.mockReturnValue({
im: { message: { create: mockClientCreate } },
});
});
describe("Concurrent Feishu sends — happy path", () => {
it("all concurrent sends succeed when API responds without errors", async () => {
const CONCURRENCY = 10;
let n = 0;
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_happy_${n++}`)));
const results = await Promise.all(
Array.from({ length: CONCURRENCY }, (_, i) =>
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_chat_${i}`, text: `Message ${i}` }),
),
);
expect(results).toHaveLength(CONCURRENCY);
for (const result of results) {
expect(result.messageId).toBeTruthy();
expect(result.receipt).toBeDefined();
}
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY);
});
it("sends 20 messages concurrently and all resolve independently", async () => {
const CONCURRENCY = 20;
let n = 0;
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_concurrent_${n++}`)));
const results = await Promise.all(
Array.from({ length: CONCURRENCY }, (_, i) =>
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_stress", text: `stress-${i}` }),
),
);
expect(results).toHaveLength(CONCURRENCY);
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY);
// All message IDs should be unique
const messageIds = results.map((r) => r.messageId);
expect(new Set(messageIds).size).toBe(CONCURRENCY);
});
});
describe("Concurrent Feishu sends — rate-limit behavior (code 230020)", () => {
afterEach(() => {
vi.useRealTimers();
});
it("throws on rate-limit code 230020 after exhausting retries", async () => {
vi.useFakeTimers();
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
// Promise.allSettled attaches a rejection handler synchronously, so when
// vi.runAllTimersAsync advances timers and fires the rejection, it is
// already handled. Using expect().rejects.toThrow() would defer the
// attachment via Promise.resolve().then(), causing an unhandled-rejection
// warning before the handler is registered.
const settled = Promise.allSettled([
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_rl", text: "rate limited" }),
]);
await vi.runAllTimersAsync();
const [result] = await settled;
expect(result.status).toBe("rejected");
// 1 initial attempt + 2 retries = 3 total calls
expect(mockClientCreate).toHaveBeenCalledTimes(3);
});
it("some concurrent sends fail with rate-limit while others succeed", async () => {
vi.useFakeTimers();
const HALF = 4;
let n = 0;
// Distinguish sends by receive_id: targets containing "fail" always rate-limit.
mockClientCreate.mockImplementation((params: { data?: { receive_id?: string } }) => {
const target = params?.data?.receive_id ?? "";
if (target.includes("fail")) {
return Promise.reject(axiosRateLimitError());
}
return Promise.resolve(okResponse(`om_ok_${n++}`));
});
const settled = Promise.allSettled([
...Array.from({ length: HALF }, (_, i) =>
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_fail_${i}`, text: `fail-${i}` }),
),
...Array.from({ length: HALF }, (_, i) =>
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_ok_${i}`, text: `ok-${i}` }),
),
]);
await vi.runAllTimersAsync();
const results = await settled;
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(HALF);
expect(results.filter((r) => r.status === "rejected")).toHaveLength(HALF);
// Rate-limited sends: HALF × 3 calls (1 + 2 retries); successful sends: HALF × 1 call
expect(mockClientCreate).toHaveBeenCalledTimes(HALF * 3 + HALF);
});
it("all concurrent sends fail gracefully when API consistently rate-limits", async () => {
vi.useFakeTimers();
const CONCURRENCY = 5;
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
const settled = Promise.allSettled(
Array.from({ length: CONCURRENCY }, (_, i) =>
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_all_fail", text: `msg-${i}` }),
),
);
await vi.runAllTimersAsync();
const results = await settled;
expect(results.every((r) => r.status === "rejected")).toBe(true);
// Each send retries twice: CONCURRENCY × 3 total calls
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY * 3);
});
it("recovers when API rate-limits once then succeeds", async () => {
vi.useFakeTimers();
let n = 0;
mockClientCreate
.mockRejectedValueOnce(axiosRateLimitError(230020))
.mockImplementation(() => Promise.resolve(okResponse(`om_recovered_${n++}`)));
const sendPromise = sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_recover", text: "recover" });
await vi.runAllTimersAsync();
const result = await sendPromise;
expect(result.messageId).toMatch(/^om_recovered_/);
// 1 rate-limited call + 1 successful retry
expect(mockClientCreate).toHaveBeenCalledTimes(2);
});
it("rate-limit error message surfaces feishu_code for caller detection", async () => {
vi.useFakeTimers();
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
// Same pattern: allSettled attaches the handler synchronously before timers advance.
const settled = Promise.allSettled([
sendMessageFeishu({
cfg: MOCK_CFG,
to: "oc_err_msg",
text: "check error message",
}),
]);
await vi.runAllTimersAsync();
const [result] = await settled;
expect(result.status).toBe("rejected");
const error = result.status === "rejected" ? result.reason : null;
expect(error).toBeInstanceOf(Error);
// Error message must carry feishu_code so retry/circuit-breaker logic upstream can identify it
expect((error as Error).message).toMatch(/230020/);
});
});
describe("Concurrent Feishu sends — timing and ordering", () => {
it("concurrent sends complete faster than sequential would (all fire in parallel)", async () => {
const CONCURRENCY = 5;
const SIMULATED_DELAY_MS = 20;
let n = 0;
mockClientCreate.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve(okResponse(`om_timed_${n++}`)), SIMULATED_DELAY_MS);
}),
);
const start = Date.now();
const results = await Promise.all(
Array.from({ length: CONCURRENCY }, (_, i) =>
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_timed_${i}`, text: `msg ${i}` }),
),
);
const elapsed = Date.now() - start;
expect(results).toHaveLength(CONCURRENCY);
// Concurrent: should complete in roughly 1x delay, not CONCURRENCY * delay
expect(elapsed).toBeLessThan(SIMULATED_DELAY_MS * CONCURRENCY);
});
it("sends to multiple distinct targets resolve independently", async () => {
const targets = ["oc_alpha", "oc_beta", "oc_gamma"];
let n = 0;
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_target_${n++}`)));
const results = await Promise.all(
targets.map((to) => sendMessageFeishu({ cfg: MOCK_CFG, to, text: "hello" })),
);
expect(results).toHaveLength(targets.length);
for (const result of results) {
expect(result.messageId).toBeTruthy();
}
expect(mockClientCreate).toHaveBeenCalledTimes(targets.length);
});
});

View File

@@ -0,0 +1,323 @@
/**
* Unit tests for requestFeishuApi retry logic and getFeishuSendRateLimitCode.
*
* Tests the retry behaviour directly via requestFeishuApi with retryDelayMs:0
* so no fake timers are needed. Related: issue #70879.
*/
import { describe, expect, it, vi } from "vitest";
import {
getFeishuSendRateLimitCode,
getFeishuSendRateLimitCodeFromResponse,
requestFeishuApi,
} from "./comment-shared.js";
/** Build an AxiosError-shaped object for a given Feishu body error code (HTTP 400). */
function axiosError(code: number) {
return Object.assign(new Error("Request failed with status code 400"), {
response: {
status: 400,
data: { code, msg: "feishu error" },
},
});
}
/**
* Build an AxiosError-shaped object for a Feishu Open API gateway HTTP 429
* response (no Feishu business code in body — gateway short-circuits before
* the message service).
*/
function http429Error() {
return Object.assign(new Error("Request failed with status code 429"), {
response: {
status: 429,
data: { msg: "Too Many Requests" },
headers: { "x-ogw-ratelimit-reset": "1" },
},
});
}
// Use retryDelayMs: 0 throughout to keep tests fast with no real delays.
const NO_DELAY = { retryDelayMs: 0 };
describe("getFeishuSendRateLimitCode", () => {
it("returns 230020 for per-chat rate-limit AxiosError", () => {
expect(getFeishuSendRateLimitCode(axiosError(230020))).toBe(230020);
});
it("returns undefined for 230006 (not a transient rate limit)", () => {
expect(getFeishuSendRateLimitCode(axiosError(230006))).toBeUndefined();
});
it("returns undefined for a non-rate-limit code", () => {
expect(getFeishuSendRateLimitCode(axiosError(230001))).toBeUndefined();
});
it("returns undefined for a plain Error (no response shape)", () => {
expect(getFeishuSendRateLimitCode(new Error("boom"))).toBeUndefined();
});
it("returns undefined for null", () => {
expect(getFeishuSendRateLimitCode(null)).toBeUndefined();
});
});
describe("requestFeishuApi — success path", () => {
it("resolves immediately on first attempt", async () => {
const request = vi.fn().mockResolvedValue("ok");
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect(result).toBe("ok");
expect(request).toHaveBeenCalledTimes(1);
});
});
describe("requestFeishuApi — retry on rate-limit", () => {
it("retries once and succeeds on second attempt (code 230020)", async () => {
const request = vi
.fn()
.mockRejectedValueOnce(axiosError(230020))
.mockResolvedValueOnce("ok-retry");
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect(result).toBe("ok-retry");
expect(request).toHaveBeenCalledTimes(2);
});
it("does not retry on code 230006", async () => {
const request = vi.fn().mockRejectedValue(axiosError(230006));
await expect(requestFeishuApi(request, "prefix", NO_DELAY)).rejects.toThrow();
expect(request).toHaveBeenCalledTimes(1);
});
it("exhausts all retries and throws after 3 total attempts", async () => {
const request = vi.fn().mockRejectedValue(axiosError(230020));
await expect(requestFeishuApi(request, "Feishu send failed", NO_DELAY)).rejects.toThrow(
/Feishu send failed/,
);
// 1 initial attempt + 2 retries
expect(request).toHaveBeenCalledTimes(3);
});
it("wraps the final error with feishu_code in the message", async () => {
const request = vi.fn().mockRejectedValue(axiosError(230020));
const err = await requestFeishuApi(request, "Feishu send failed", NO_DELAY).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/230020/);
});
it("recovers on the third attempt after two rate-limit failures", async () => {
const request = vi
.fn()
.mockRejectedValueOnce(axiosError(230020))
.mockRejectedValueOnce(axiosError(230020))
.mockResolvedValueOnce("ok-third");
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect(result).toBe("ok-third");
expect(request).toHaveBeenCalledTimes(3);
});
});
describe("requestFeishuApi — no retry for non-rate-limit errors", () => {
it("throws immediately without retry for a non-rate-limit Feishu code", async () => {
const request = vi.fn().mockRejectedValue(axiosError(230001));
await expect(requestFeishuApi(request, "prefix", NO_DELAY)).rejects.toThrow();
expect(request).toHaveBeenCalledTimes(1);
});
it("throws immediately without retry for a plain Error", async () => {
const request = vi.fn().mockRejectedValue(new Error("network failure"));
await expect(requestFeishuApi(request, "prefix", NO_DELAY)).rejects.toThrow(/network failure/);
expect(request).toHaveBeenCalledTimes(1);
});
});
describe("getFeishuSendRateLimitCode — expanded rate-limit signals", () => {
// 11232 is the tenant-level "create message service trigger rate limit"
// (100/min, 5/sec). Same nature as 230020 (per-chat) but at a higher scope.
it("returns 11232 for tenant-level message rate-limit AxiosError", () => {
expect(getFeishuSendRateLimitCode(axiosError(11232))).toBe(11232);
});
// HTTP 429 is the Feishu Open API gateway-level limit (app-wide quota);
// it short-circuits before hitting the message service so the body has no
// Feishu business code. We must detect it from response.status alone.
it("returns 429 for gateway-level HTTP 429 with no business code in body", () => {
expect(getFeishuSendRateLimitCode(http429Error())).toBe(429);
});
it("prefers HTTP 429 over body code when both are present", () => {
const err = Object.assign(new Error("Request failed with status code 429"), {
response: {
status: 429,
data: { code: 230001, msg: "ignored" },
},
});
expect(getFeishuSendRateLimitCode(err)).toBe(429);
});
});
describe("requestFeishuApi — retry on expanded rate-limit signals", () => {
it("retries once and succeeds on second attempt (code 11232)", async () => {
const request = vi
.fn()
.mockRejectedValueOnce(axiosError(11232))
.mockResolvedValueOnce("ok-after-11232");
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect(result).toBe("ok-after-11232");
expect(request).toHaveBeenCalledTimes(2);
});
it("retries once and succeeds on second attempt (HTTP 429)", async () => {
const request = vi
.fn()
.mockRejectedValueOnce(http429Error())
.mockResolvedValueOnce("ok-after-429");
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect(result).toBe("ok-after-429");
expect(request).toHaveBeenCalledTimes(2);
});
it("exhausts retries on persistent 11232 and surfaces feishu_code", async () => {
const request = vi.fn().mockRejectedValue(axiosError(11232));
const err = await requestFeishuApi(request, "Feishu send failed", NO_DELAY).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/11232/);
// 1 initial attempt + 2 retries
expect(request).toHaveBeenCalledTimes(3);
});
it("exhausts retries on persistent HTTP 429 and surfaces http_status", async () => {
const request = vi.fn().mockRejectedValue(http429Error());
const err = await requestFeishuApi(request, "Feishu send failed", NO_DELAY).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
// The error wrapper records http_status:429 in the JSON-encoded message.
expect((err as Error).message).toMatch(/429/);
expect(request).toHaveBeenCalledTimes(3);
});
it("recovers across mixed rate-limit signals (230020 → 11232 → ok)", async () => {
const request = vi
.fn()
.mockRejectedValueOnce(axiosError(230020))
.mockRejectedValueOnce(axiosError(11232))
.mockResolvedValueOnce("ok-mixed");
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect(result).toBe("ok-mixed");
expect(request).toHaveBeenCalledTimes(3);
});
});
// Feishu SDK can fulfill (no throw) with a rate-limit code in the body, e.g.
// `{ code: 11232, msg: "..." }`. Same shape the typing path already handles
// via getBackoffCodeFromResponse — see issue #28157.
describe("getFeishuSendRateLimitCodeFromResponse — fulfilled body classification", () => {
it("returns 230020 for a fulfilled per-chat rate-limit body", () => {
expect(getFeishuSendRateLimitCodeFromResponse({ code: 230020, msg: "rate limit" })).toBe(
230020,
);
});
it("returns 11232 for a fulfilled tenant-level rate-limit body", () => {
expect(getFeishuSendRateLimitCodeFromResponse({ code: 11232, msg: "rate limit" })).toBe(11232);
});
it("returns undefined for a successful body (code=0)", () => {
expect(getFeishuSendRateLimitCodeFromResponse({ code: 0, data: {} })).toBeUndefined();
});
it("returns undefined for a non-rate-limit error body", () => {
expect(getFeishuSendRateLimitCodeFromResponse({ code: 230001 })).toBeUndefined();
});
it("returns undefined for null / non-object", () => {
expect(getFeishuSendRateLimitCodeFromResponse(null)).toBeUndefined();
expect(getFeishuSendRateLimitCodeFromResponse(undefined)).toBeUndefined();
expect(getFeishuSendRateLimitCodeFromResponse("oops")).toBeUndefined();
});
});
describe("requestFeishuApi — retry on fulfilled rate-limit body (no throw)", () => {
// Mirrors the typing-path fix from #28157: SDK resolves with the rate-limit
// code instead of throwing, and the helper must classify before returning.
it("retries when SDK fulfills with code 11232 then succeeds", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({ code: 11232, msg: "rate limit", data: null })
.mockResolvedValueOnce({ code: 0, data: { message_id: "om_ok" } });
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect(result).toEqual({ code: 0, data: { message_id: "om_ok" } });
expect(request).toHaveBeenCalledTimes(2);
});
it("retries when SDK fulfills with code 230020 then succeeds", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({ code: 230020, msg: "rate limit" })
.mockResolvedValueOnce({ code: 0, data: { message_id: "om_ok2" } });
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect((result as { data: { message_id: string } }).data.message_id).toBe("om_ok2");
expect(request).toHaveBeenCalledTimes(2);
});
it("exhausts retries when SDK keeps fulfilling 11232 and throws wrapped error", async () => {
const request = vi.fn().mockResolvedValue({ code: 11232, msg: "rate limit" });
const err = await requestFeishuApi(request, "Feishu send failed", NO_DELAY).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/Feishu send failed/);
expect((err as Error).message).toMatch(/11232/);
// 1 initial attempt + 2 retries
expect(request).toHaveBeenCalledTimes(3);
});
it("does not retry when SDK fulfills with a successful response (code=0)", async () => {
const request = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_first" } });
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect((result as { data: { message_id: string } }).data.message_id).toBe("om_first");
expect(request).toHaveBeenCalledTimes(1);
});
it("does not retry when SDK fulfills with a non-rate-limit error code", async () => {
// Non-retryable error codes pass through to assertFeishuMessageApiSuccess upstream.
const fulfilled = { code: 230001, msg: "permission error" };
const request = vi.fn().mockResolvedValue(fulfilled);
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect(result).toBe(fulfilled);
expect(request).toHaveBeenCalledTimes(1);
});
it("recovers across thrown then fulfilled rate-limits (catch → try → ok)", async () => {
const request = vi
.fn()
.mockRejectedValueOnce(axiosError(230020))
.mockResolvedValueOnce({ code: 11232, msg: "rate limit" })
.mockResolvedValueOnce({ code: 0, data: { message_id: "om_recovered" } });
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
expect((result as { data: { message_id: string } }).data.message_id).toBe("om_recovered");
expect(request).toHaveBeenCalledTimes(3);
});
});

View File

@@ -10,7 +10,7 @@ import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
import type { ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { createFeishuApiError, requestFeishuApi } from "./comment-shared.js";
import { requestFeishuApi } from "./comment-shared.js";
import type { MentionTarget } from "./mention-target.types.js";
import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
import { parsePostContent } from "./post.js";
@@ -67,6 +67,11 @@ function isWithdrawnReplyError(err: unknown): boolean {
) {
return true;
}
// Wrapped error shape from createFeishuApiError: err.cause holds the original error.
const cause = (err as { cause?: unknown }).cause;
if (cause && cause !== err) {
return isWithdrawnReplyError(cause);
}
return false;
}
@@ -169,17 +174,22 @@ async function sendReplyOrFallbackDirect(
let response: { code?: number; msg?: string; data?: { message_id?: string } };
try {
response = await client.im.message.reply({
path: { message_id: params.replyToMessageId },
data: {
content: params.content,
msg_type: params.msgType,
...(params.replyInThread ? { reply_in_thread: true } : {}),
},
});
response = await requestFeishuApi(
() =>
client.im.message.reply({
path: { message_id: params.replyToMessageId! },
data: {
content: params.content,
msg_type: params.msgType,
...(params.replyInThread ? { reply_in_thread: true } : {}),
},
}),
params.replyErrorPrefix,
{ includeNestedErrorLogId: true },
);
} catch (err) {
if (!isWithdrawnReplyError(err)) {
throw createFeishuApiError(err, params.replyErrorPrefix, { includeNestedErrorLogId: true });
throw err;
}
if (replyTargetFallbackError) {
throw replyTargetFallbackError;

View File

@@ -10,6 +10,7 @@ import {
} from "openclaw/plugin-sdk/number-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { getFeishuUserAgent } from "./client.js";
import { requestFeishuApi } from "./comment-shared.js";
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
import type { FeishuDomain } from "./types.js";
@@ -295,32 +296,44 @@ export class FeishuStreamingSession {
const sendOptions = options ?? {};
const sendMode = resolveStreamingCardSendMode(sendOptions);
if (sendMode === "reply") {
sendRes = await this.client.im.message.reply({
path: { message_id: sendOptions.replyToMessageId! },
data: {
msg_type: "interactive",
content: cardContent,
...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
},
});
sendRes = await requestFeishuApi(
() =>
this.client.im.message.reply({
path: { message_id: sendOptions.replyToMessageId! },
data: {
msg_type: "interactive",
content: cardContent,
...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
},
}),
"Send card failed",
);
} else if (sendMode === "root_create") {
// root_id is undeclared in the SDK types but accepted at runtime
sendRes = await this.client.im.message.create({
params: { receive_id_type: receiveIdType },
data: Object.assign(
{ receive_id: receiveId, msg_type: "interactive", content: cardContent },
{ root_id: sendOptions.rootId },
),
});
sendRes = await requestFeishuApi(
() =>
this.client.im.message.create({
params: { receive_id_type: receiveIdType },
data: Object.assign(
{ receive_id: receiveId, msg_type: "interactive", content: cardContent },
{ root_id: sendOptions.rootId },
),
}),
"Send card failed",
);
} else {
sendRes = await this.client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: "interactive",
content: cardContent,
},
});
sendRes = await requestFeishuApi(
() =>
this.client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: "interactive",
content: cardContent,
},
}),
"Send card failed",
);
}
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
throw new Error(`Send card failed: ${sendRes.msg}`);

View File

@@ -55,6 +55,12 @@ export type RunIMessageCatchupParams = {
* (including non-error drops, which mirrors the live pipeline).
*/
dispatchPayload: (message: IMessagePayload) => Promise<void>;
/**
* Called for `is_from_me=true` rows that catchup intentionally does not
* dispatch. The live inbound path still needs to observe those rows so
* self-chat reflected companion rows can be deduped.
*/
observeSkippedFromMePayload?: (message: IMessagePayload) => Promise<void> | void;
runtime?: RuntimeLogger;
/** Override clock for tests. */
now?: () => number;
@@ -277,6 +283,14 @@ export async function runIMessageCatchup(
config,
fetch: fetchFn,
dispatch: dispatchFn,
observeSkippedFromMe: async (row) => {
const payload = payloadByGuid.get(row.guid);
if (!payload) {
warnLog(`imessage catchup: missing skipped from-me payload for guid=${row.guid}`);
return;
}
await params.observeSkippedFromMePayload?.(payload);
},
log,
warn: warnLog,
...(params.now ? { now: params.now() } : {}),

View File

@@ -261,6 +261,7 @@ describe("performIMessageCatchup", () => {
it("skips is_from_me rows but still advances the cursor past them", async () => {
const dispatch = alwaysOk();
const observeSkippedFromMe = vi.fn();
const fetch = fetchOf([
row({ guid: "A", rowid: 10, isFromMe: true }),
row({ guid: "B", rowid: 11, isFromMe: false }),
@@ -272,12 +273,16 @@ describe("performIMessageCatchup", () => {
now,
fetch,
dispatch,
observeSkippedFromMe,
});
expect(summary.skippedFromMe).toBe(1);
expect(summary.replayed).toBe(1);
expect(summary.cursorAfter.lastSeenRowid).toBe(11);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(observeSkippedFromMe).toHaveBeenCalledWith(
expect.objectContaining({ guid: "A", rowid: 10, isFromMe: true }),
);
});
it("drops rows older than the maxAgeMinutes ceiling and advances past them", async () => {

View File

@@ -326,6 +326,7 @@ export type PerformCatchupParams = {
now?: number;
fetch: CatchupFetchFn;
dispatch: CatchupDispatchFn;
observeSkippedFromMe?: (row: IMessageCatchupRow) => Promise<void> | void;
log?: (message: string) => void;
warn?: (message: string) => void;
};
@@ -462,6 +463,13 @@ export async function performIMessageCatchup(
continue;
}
if (row.isFromMe) {
try {
await params.observeSkippedFromMe?.(row);
} catch (err) {
params.warn?.(
`imessage catchup: from-me observer failed for guid=${row.guid}: ${String(err)}`,
);
}
summary.skippedFromMe += 1;
highWatermarkMs = Math.max(highWatermarkMs, row.date);
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);

View File

@@ -67,24 +67,24 @@ describe("deliverReplies", () => {
[
"chat_id:10",
"first",
{
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
maxBytes: 4096,
client,
accountId: "default",
replyToId: "reply-1",
},
}),
],
[
"chat_id:10",
"second",
{
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
maxBytes: 4096,
client,
accountId: "default",
replyToId: "reply-1",
},
}),
],
]);
});
@@ -112,26 +112,26 @@ describe("deliverReplies", () => {
[
"chat_id:20",
"caption",
{
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
mediaUrl: "https://example.com/a.jpg",
maxBytes: 8192,
client,
accountId: "acct-2",
replyToId: "reply-2",
},
}),
],
[
"chat_id:20",
"",
{
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
mediaUrl: "https://example.com/b.jpg",
maxBytes: 8192,
client,
accountId: "acct-2",
replyToId: "reply-2",
},
}),
],
]);
});
@@ -157,11 +157,11 @@ describe("deliverReplies", () => {
[
"chat_id:50",
"durable hello",
{
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
client,
},
}),
],
]);
expect(remember).toHaveBeenCalledWith("acct-5:chat_id:50", {
@@ -191,11 +191,11 @@ describe("deliverReplies", () => {
[
"chat_id:60",
"Visible reply",
{
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
client,
},
}),
],
]);
expect(remember).toHaveBeenCalledWith("acct-6:chat_id:60", {
@@ -204,10 +204,9 @@ describe("deliverReplies", () => {
});
});
it("records outbound text and message ids in sent-message cache (post-send only)", async () => {
// Fix for #47830: remember() is called ONLY after each chunk is sent,
// never with the full un-chunked text before sending begins.
// Pre-send population widened the false-positive window in self-chat.
it("records outbound text and message ids in sent-message cache after send", async () => {
// Fix for #47830: monitor cache population remains per chunk, never the
// full un-chunked text before sending begins.
const remember = vi.fn();
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
sendMessageIMessageMock
@@ -226,15 +225,13 @@ describe("deliverReplies", () => {
sentMessageCache: { remember },
});
// Only the two per-chunk post-send calls — no pre-send full-text call.
expect(remember).toHaveBeenCalledTimes(2);
expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", {
text: "first",
messageId: "imsg-1",
});
expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", {
text: "second",
messageId: "imsg-2",
expect(remember.mock.calls).toStrictEqual([
["acct-3:chat_id:30", { text: "first", messageId: "imsg-1" }],
["acct-3:chat_id:30", { text: "second", messageId: "imsg-2" }],
]);
expect(remember).not.toHaveBeenCalledWith("acct-3:chat_id:30", {
text: "first|second",
});
});

View File

@@ -55,10 +55,6 @@ export async function deliverReplies(params: {
accountId,
replyToId: payload.replyToId,
});
// Post-send cache population (#47830): caching happens after each chunk is sent,
// not before. The window between send completion and cache write is sub-millisecond;
// the next SQLite inbound poll is 1-2s away, so no echo can arrive before the
// cache entry exists.
sentMessageCache?.remember(scope, {
text: sent.echoText ?? sent.sentText,
messageId: sent.messageId,

View File

@@ -6,6 +6,11 @@ type SentMessageLookup = {
messageId?: string;
};
type SentMessageLookupOptions = {
skipIdShortCircuit?: boolean;
includePendingText?: boolean;
};
export type SentMessageCache = {
remember: (scope: string, lookup: SentMessageLookup) => void;
/**
@@ -17,7 +22,11 @@ export type SentMessageCache = {
* that will never match the GUID outbound IDs, but text matching is still
* the right way to identify agent reply echoes.
*/
has: (scope: string, lookup: SentMessageLookup, skipIdShortCircuit?: boolean) => boolean;
has: (
scope: string,
lookup: SentMessageLookup,
options?: boolean | SentMessageLookupOptions,
) => boolean;
};
// Echo arrival observed at ~2.2s on M4 Mac Mini (SQLite poll interval is the bottleneck).
@@ -70,9 +79,21 @@ class DefaultSentMessageCache implements SentMessageCache {
this.cleanup();
}
has(scope: string, lookup: SentMessageLookup, skipIdShortCircuit = false): boolean {
has(
scope: string,
lookup: SentMessageLookup,
options: boolean | SentMessageLookupOptions = false,
): boolean {
this.cleanup();
if (hasPersistedIMessageEcho({ scope, ...lookup })) {
const resolvedOptions =
typeof options === "boolean" ? { skipIdShortCircuit: options } : options;
if (
hasPersistedIMessageEcho({
scope,
...lookup,
includePendingText: resolvedOptions.includePendingText,
})
) {
return true;
}
const textKey = normalizeEchoTextKey(lookup.text);
@@ -89,7 +110,7 @@ class DefaultSentMessageCache implements SentMessageCache {
const hasTextOnlyMatch =
typeof textTimestamp === "number" &&
(!textBackedByIdTimestamp || textTimestamp > textBackedByIdTimestamp);
if (!skipIdShortCircuit && !hasTextOnlyMatch) {
if (!resolvedOptions.skipIdShortCircuit && !hasTextOnlyMatch) {
return false;
}
}

View File

@@ -193,18 +193,67 @@ function resolveInboundEchoMessageIds(message: IMessagePayload): string[] {
return ids;
}
export function rememberIMessageSkippedFromMeForSelfChatDedupe(params: {
accountId: string;
message: IMessagePayload;
bodyText: string;
selfChatCache?: SelfChatCache;
}): void {
if (params.message.is_from_me !== true) {
return;
}
const sender = params.message.sender?.trim();
if (!sender) {
return;
}
const chatId = params.message.chat_id ?? undefined;
const isGroup = Boolean(params.message.is_group);
const chatIdentifierNormalized =
normalizeIMessageHandle(params.message.chat_identifier ?? "") || undefined;
const destinationCallerIdNormalized =
normalizeIMessageHandle(params.message.destination_caller_id ?? "") || undefined;
const senderNormalized = normalizeIMessageHandle(sender);
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
const lookup = {
accountId: params.accountId,
isGroup,
chatId,
sender,
text: params.bodyText.trim(),
createdAt,
};
const matchesSelfChatDestination =
destinationCallerIdNormalized != null && destinationCallerIdNormalized === senderNormalized;
const isSelfChat =
!isGroup &&
chatIdentifierNormalized != null &&
senderNormalized === chatIdentifierNormalized &&
matchesSelfChatDestination;
const isAmbiguousSelfThread =
!isGroup &&
chatIdentifierNormalized != null &&
senderNormalized === chatIdentifierNormalized &&
destinationCallerIdNormalized == null;
if (isSelfChat) {
params.selfChatCache?.remember({ ...lookup, allowCreatedAtSkew: true });
} else if (isAmbiguousSelfThread) {
params.selfChatCache?.remember(lookup);
}
}
function hasIMessageEchoMatch(params: {
echoCache: {
has: (
scope: string,
lookup: { text?: string; messageId?: string },
skipIdShortCircuit?: boolean,
options?: boolean | { skipIdShortCircuit?: boolean; includePendingText?: boolean },
) => boolean;
};
scope: string | readonly string[];
text?: string;
messageIds: string[];
skipIdShortCircuit?: boolean;
includePendingText?: boolean;
}): boolean {
// Outbound sends persist echo scopes keyed by whichever target shape was
// used (chat_id, chat_guid, chat_identifier, or imessage:<handle>). Inbound
@@ -232,7 +281,10 @@ function hasIMessageEchoMatch(params: {
params.echoCache.has(
scope,
{ text: params.text, messageId: fallbackMessageId },
params.skipIdShortCircuit,
{
skipIdShortCircuit: params.skipIdShortCircuit,
includePendingText: params.includePendingText,
},
)
) {
return true;
@@ -349,7 +401,7 @@ export async function resolveIMessageInboundDecision(params: {
has: (
scope: string,
lookup: { text?: string; messageId?: string },
skipIdShortCircuit?: boolean,
options?: boolean | { skipIdShortCircuit?: boolean; includePendingText?: boolean },
) => boolean;
};
selfChatCache?: SelfChatCache;
@@ -453,6 +505,7 @@ export async function resolveIMessageInboundDecision(params: {
text: bodyText || undefined,
messageIds: inboundMessageIds,
skipIdShortCircuit: !hasInboundGuid,
includePendingText: true,
})
) {
return { kind: "drop", reason: "agent echo in self-chat" };
@@ -649,6 +702,7 @@ export async function resolveIMessageInboundDecision(params: {
scope: echoScope,
text: bodyText || undefined,
messageIds: inboundMessageIds,
includePendingText: isSelfChat,
})
) {
params.logVerbose?.(

View File

@@ -117,6 +117,23 @@ describe("iMessage sent-message echo cache", () => {
expect(cache.has(scope, { messageId: "id-only" })).toBe(true);
});
it("keeps short-lived pending persisted echoes out of generic text matching", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
const scope = "acct:imessage:+1555";
rememberPersistedIMessageEcho({ scope, text: "pending-send", ttlMs: 1_000, pending: true });
expect(hasPersistedIMessageEcho({ scope, text: "pending-send" })).toBe(false);
expect(
hasPersistedIMessageEcho({ scope, text: "pending-send", includePendingText: true }),
).toBe(true);
vi.advanceTimersByTime(1_001);
expect(
hasPersistedIMessageEcho({ scope, text: "pending-send", includePendingText: true }),
).toBe(false);
});
it("refreshes persisted echoes written after an earlier empty lookup", () => {
const cache = createSentMessageCache();
const scope = "acct:imessage:+1555";

View File

@@ -93,6 +93,7 @@ import {
} from "./inbound-dedupe.js";
import {
buildIMessageInboundContext,
rememberIMessageSkippedFromMeForSelfChatDedupe,
resolveIMessageReactionContext,
resolveIMessageInboundDecision,
} from "./inbound-processing.js";
@@ -728,14 +729,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
}
}
async function handleMessageNowInner(rawMessage: IMessagePayload) {
const message = await repairMessageConversationAnchor(rawMessage);
if (!message) {
return;
}
function resolveIMessageInboundBodyText(message: IMessagePayload) {
const messageText = (message.text ?? "").trim();
const attachments = includeAttachments ? (message.attachments ?? []) : [];
const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots;
const validAttachments = attachments.filter((entry) => {
@@ -769,7 +764,28 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
: validAttachments.length
? "<media:attachment>"
: "";
const bodyText = messageText || placeholder;
return {
messageText,
bodyText: messageText || placeholder,
validAttachments,
rawMediaAttachments,
effectiveAttachmentRoots,
};
}
async function handleMessageNowInner(rawMessage: IMessagePayload) {
const message = await repairMessageConversationAnchor(rawMessage);
if (!message) {
return;
}
const {
messageText,
bodyText,
validAttachments,
rawMediaAttachments,
effectiveAttachmentRoots,
} = resolveIMessageInboundBodyText(message);
// Approval reaction shortcut: if the inbound tapback resolves a pending
// approval prompt, route it through the gateway and skip the normal
@@ -1498,6 +1514,15 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
config: catchupCfg,
includeAttachments,
dispatchPayload: (message) => handleMessageNow(message, { advanceCatchupCursor: false }),
observeSkippedFromMePayload: (message) => {
const { bodyText } = resolveIMessageInboundBodyText(message);
rememberIMessageSkippedFromMeForSelfChatDedupe({
accountId: accountInfo.accountId,
message,
bodyText,
selfChatCache,
});
},
runtime,
});
liveCatchupCursorAdvanceEnabled =

View File

@@ -9,6 +9,8 @@ type PersistedEchoEntry = {
text?: string;
messageId?: string;
timestamp: number;
expiresAt?: number;
pending?: true;
};
// 12h comfortably outlives the inbound replay guard window
@@ -65,13 +67,24 @@ function remainingTtlMs(timestamp: number): number | undefined {
return remaining > 0 ? remaining : undefined;
}
function resolveEntryTtlMs(entry: PersistedEchoEntry, ttlMs?: number): number | undefined {
if (typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0) {
return ttlMs;
}
return remainingTtlMs(entry.timestamp);
}
function isLiveEntry(entry: PersistedEchoEntry, now = Date.now()): boolean {
const cutoff = now - IMESSAGE_SENT_ECHOES_TTL_MS;
return entry.timestamp >= cutoff && (entry.expiresAt == null || entry.expiresAt > now);
}
function loadMirrorFromStore(): void {
try {
const cutoff = Date.now() - IMESSAGE_SENT_ECHOES_TTL_MS;
mirror = openPersistedEchoStore()
.entries()
.map(({ value }) => value)
.filter((entry) => entry.timestamp >= cutoff)
.filter((entry) => isLiveEntry(entry))
.toSorted((a, b) => a.timestamp - b.timestamp)
.slice(-IMESSAGE_SENT_ECHOES_MAX_ENTRIES);
} catch (err) {
@@ -85,23 +98,30 @@ function readRecentEntries(): PersistedEchoEntry[] {
return mirror ?? [];
}
function persistEntry(entry: PersistedEchoEntry): void {
const ttlMs = remainingTtlMs(entry.timestamp);
if (!ttlMs) {
return;
function persistEntry(entry: PersistedEchoEntry, ttlMs?: number): string | undefined {
const effectiveTtlMs = resolveEntryTtlMs(entry, ttlMs);
if (!effectiveTtlMs) {
return undefined;
}
const key = resolveIMessageSentEchoEntryKey(entry);
try {
openPersistedEchoStore().register(resolveIMessageSentEchoEntryKey(entry), entry, { ttlMs });
openPersistedEchoStore().register(key, entry, {
ttlMs: effectiveTtlMs,
});
} catch (err) {
reportFailure("write", err);
return undefined;
}
return key;
}
export function rememberPersistedIMessageEcho(params: {
scope: string;
text?: string;
messageId?: string;
}): void {
ttlMs?: number;
pending?: boolean;
}): string | undefined {
const text = normalizeText(params.text);
const messageId = normalizeMessageId(params.messageId);
const entry: PersistedEchoEntry = {
@@ -109,22 +129,39 @@ export function rememberPersistedIMessageEcho(params: {
timestamp: Date.now(),
...(text ? { text } : {}),
...(messageId ? { messageId } : {}),
...(params.pending ? { pending: true } : {}),
};
if (typeof params.ttlMs === "number" && Number.isFinite(params.ttlMs) && params.ttlMs > 0) {
entry.expiresAt = entry.timestamp + params.ttlMs;
}
if (!entry.text && !entry.messageId) {
return;
return undefined;
}
loadMirrorFromStore();
persistEntry(entry);
const cutoff = Date.now() - IMESSAGE_SENT_ECHOES_TTL_MS;
const key = persistEntry(entry, params.ttlMs);
mirror = [...(mirror ?? []), entry]
.filter((candidate) => candidate.timestamp >= cutoff)
.filter((candidate) => isLiveEntry(candidate))
.slice(-IMESSAGE_SENT_ECHOES_MAX_ENTRIES);
return key;
}
export function forgetPersistedIMessageEchoKey(key: string | undefined): void {
if (!key) {
return;
}
try {
openPersistedEchoStore().delete(key);
} catch (err) {
reportFailure("delete", err);
}
mirror = (mirror ?? []).filter((entry) => resolveIMessageSentEchoEntryKey(entry) !== key);
}
export function hasPersistedIMessageEcho(params: {
scope: string;
text?: string;
messageId?: string;
includePendingText?: boolean;
}): boolean {
const text = normalizeText(params.text);
const messageId = normalizeMessageId(params.messageId);
@@ -138,7 +175,7 @@ export function hasPersistedIMessageEcho(params: {
if (messageId && entry.messageId === messageId) {
return true;
}
if (text && entry.text === text) {
if (text && entry.text === text && (!entry.pending || params.includePendingText)) {
return true;
}
}

View File

@@ -3,8 +3,14 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installIMessageStateRuntimeForTest } from "../test-support/runtime.js";
import { createSentMessageCache } from "./echo-cache.js";
import { resolveIMessageInboundDecision } from "./inbound-processing.js";
import { resetPersistedIMessageEchoCacheForTest } from "./persisted-echo-cache.js";
import {
rememberIMessageSkippedFromMeForSelfChatDedupe,
resolveIMessageInboundDecision,
} from "./inbound-processing.js";
import {
rememberPersistedIMessageEcho,
resetPersistedIMessageEchoCacheForTest,
} from "./persisted-echo-cache.js";
import { createSelfChatCache } from "./self-chat-cache.js";
/**
@@ -664,6 +670,52 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
expect(reflection).toEqual({ kind: "drop", reason: "self-chat echo" });
});
it("drops catchup-replayed self-chat reflection after observing skipped from-me companion", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-03T03:48:42Z"));
const selfChatCache = createSelfChatCache();
const text = "Exactly. Ill treat assembled context as evidence only, not command authority.";
rememberIMessageSkippedFromMeForSelfChatDedupe({
accountId: "default",
message: {
id: 86798,
guid: "F502C080-08E9-4C3B-9650-31A0DF21FE3A",
sender: "+15555550123",
chat_identifier: "+15555550123",
destination_caller_id: "+15555550123",
text,
created_at: "2026-06-03T03:48:28.922Z",
is_from_me: true,
is_group: false,
},
bodyText: text,
selfChatCache,
});
const reflection = await resolveIMessageInboundDecision(
createParams({
message: {
id: 86799,
guid: "1759A121-E3DB-41C2-B16A-AB6DE30570F2",
sender: "+15555550123",
chat_identifier: "+15555550123",
destination_caller_id: "tel:+15555550123",
text,
created_at: "2026-06-03T03:48:28.738Z",
is_from_me: false,
is_group: false,
},
messageText: text,
bodyText: text,
selfChatCache,
}),
);
expect(reflection).toEqual({ kind: "drop", reason: "self-chat echo" });
});
it("does not apply sub-second skew matching to ambiguous normal DM rows", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-10T05:34:00Z"));
@@ -855,6 +907,70 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
});
describe("echo cache — text fallback for null-id inbound messages", () => {
it("does not drop normal DM text from a pending pre-send marker", async () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const scope = "default:imessage:+15551234567";
rememberPersistedIMessageEcho({
scope,
text: "same pending text",
ttlMs: 155_000,
pending: true,
});
const decision = await resolveIMessageInboundDecision(
createParams({
message: {
id: 12001,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "+15550001111",
text: "same pending text",
is_from_me: false,
is_group: false,
},
messageText: "same pending text",
bodyText: "same pending text",
echoCache,
selfChatCache,
}),
);
expect(decision.kind).toBe("dispatch");
});
it("drops self-chat reflected text from a pending pre-send marker", async () => {
const echoCache = createSentMessageCache();
const selfChatCache = createSelfChatCache();
const scope = "default:imessage:+15551234567";
rememberPersistedIMessageEcho({
scope,
text: "pending self-chat reply",
ttlMs: 155_000,
pending: true,
});
const decision = await resolveIMessageInboundDecision(
createParams({
message: {
id: 12002,
sender: "+15551234567",
chat_identifier: "+15551234567",
destination_caller_id: "tel:+15551234567",
text: "pending self-chat reply",
is_from_me: false,
is_group: false,
},
messageText: "pending self-chat reply",
bodyText: "pending self-chat reply",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "echo" });
});
it("still identifies echo via text when inbound message has id: null", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

View File

@@ -669,6 +669,69 @@ describe("sendMessageIMessage receipts", () => {
expect(result.receipt.platformMessageIds).toStrictEqual([]);
});
it("persists an echo marker before awaiting the bridge send result", async () => {
let resolveRequest!: (value: Record<string, unknown>) => void;
const client = {
request: vi.fn(
() =>
new Promise<Record<string, unknown>>((resolve) => {
resolveRequest = resolve;
}),
),
stop: vi.fn(async () => {}),
} as unknown as IMessageRpcClient;
const send = sendMessageIMessage("+15551234567", "hello", {
config: IMESSAGE_TEST_CFG,
client,
});
await vi.waitFor(() => expect(getClientMocks(client).request).toHaveBeenCalled());
expect(
hasPersistedIMessageEcho({
scope: "default:imessage:+15551234567",
text: "hello",
includePendingText: true,
}),
).toBe(true);
resolveRequest({ guid: "p:0/imsg-1" });
await expect(send).resolves.toMatchObject({ messageId: "p:0/imsg-1" });
});
it("keeps the pending echo marker alive for slow default-timeout sends", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-04T00:00:00Z"));
let resolveRequest!: (value: Record<string, unknown>) => void;
const client = {
request: vi.fn(
() =>
new Promise<Record<string, unknown>>((resolve) => {
resolveRequest = resolve;
}),
),
stop: vi.fn(async () => {}),
} as unknown as IMessageRpcClient;
const send = sendMessageIMessage("+15551234567", "slow hello", {
config: IMESSAGE_TEST_CFG,
client,
});
expect(getClientMocks(client).request).toHaveBeenCalled();
vi.advanceTimersByTime(61_000);
expect(
hasPersistedIMessageEcho({
scope: "default:imessage:+15551234567",
text: "slow hello",
includePendingText: true,
}),
).toBe(true);
resolveRequest({ guid: "p:0/imsg-slow" });
await expect(send).resolves.toMatchObject({ messageId: "p:0/imsg-slow" });
});
it("resolves numeric chat.db ROWIDs to GUIDs for approval reaction binding", async () => {
const client = createClient({ message_id: 12345 });
const resolveMessageGuidImpl = vi.fn(async () => "p:0/resolved-guid");

View File

@@ -28,7 +28,10 @@ import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
import { DEFAULT_IMESSAGE_SEND_TIMEOUT_MS } from "./constants.js";
import { extractMarkdownFormatRuns } from "./markdown-format.js";
import { rememberIMessageReplyCache } from "./monitor-reply-cache.js";
import { rememberPersistedIMessageEcho } from "./monitor/persisted-echo-cache.js";
import {
forgetPersistedIMessageEchoKey,
rememberPersistedIMessageEcho,
} from "./monitor/persisted-echo-cache.js";
import {
formatIMessageChatTarget,
type IMessageService,
@@ -38,6 +41,8 @@ import {
const require = createRequire(import.meta.url);
type ParsedIMessageTarget = ReturnType<typeof parseIMessageTarget>;
const MIN_PENDING_PERSISTED_ECHO_TTL_MS = 60_000;
const PENDING_PERSISTED_ECHO_GRACE_MS = 5_000;
type IMessageSendOpts = {
cliPath?: string;
@@ -686,6 +691,13 @@ function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function resolvePendingPersistedEchoTtlMs(timeoutMs: number): number {
return Math.max(
MIN_PENDING_PERSISTED_ECHO_TTL_MS,
Math.max(0, timeoutMs) + PENDING_PERSISTED_ECHO_GRACE_MS,
);
}
function isAttachmentCommandFallbackError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /(?:unknown|unrecognized|invalid|unsupported)\s+(?:command|subcommand)|not a recognized command|send-attachment.*(?:not found|unsupported|unavailable)|private api bridge.*unavailable|requires the imsg private api bridge|run imsg launch/iu.test(
@@ -734,6 +746,7 @@ async function trySendAttachmentForTarget(params: {
audioAsVoice?: boolean;
replyToId?: string;
echoText?: string;
pendingEchoTtlMs: number;
runCliJson: (args: readonly string[]) => Promise<Record<string, unknown>>;
resolveMessageGuidImpl?: IMessageSendOpts["resolveMessageGuidImpl"];
}): Promise<IMessageSendResult | null> {
@@ -754,8 +767,21 @@ async function trySendAttachmentForTarget(params: {
return null;
}
const echoScope = resolveOutboundEchoScope({
accountId: params.accountId,
target: params.target,
});
let result: Record<string, unknown>;
let pendingEchoKey: string | undefined;
try {
if (echoScope) {
pendingEchoKey = rememberPersistedIMessageEcho({
scope: echoScope,
text: params.echoText,
ttlMs: params.pendingEchoTtlMs,
pending: true,
});
}
result = await params.runCliJson([
"send-attachment",
"--chat",
@@ -768,6 +794,7 @@ async function trySendAttachmentForTarget(params: {
"auto",
]);
} catch (error) {
forgetPersistedIMessageEchoKey(pendingEchoKey);
if (isAttachmentCommandFallbackError(error)) {
return null;
}
@@ -776,6 +803,7 @@ async function trySendAttachmentForTarget(params: {
const failure = resolveIMessageCliFailure(result);
if (failure) {
const error = new Error(failure);
forgetPersistedIMessageEchoKey(pendingEchoKey);
if (isAttachmentCommandFallbackError(error)) {
return null;
}
@@ -790,10 +818,6 @@ async function trySendAttachmentForTarget(params: {
resolveMessageGuidImpl: params.resolveMessageGuidImpl,
});
const messageId = resolvedId ?? (result.ok || result.success ? "ok" : "unknown");
const echoScope = resolveOutboundEchoScope({
accountId: params.accountId,
target: params.target,
});
if (echoScope) {
rememberPersistedIMessageEcho({
scope: echoScope,
@@ -863,6 +887,7 @@ export async function sendMessageIMessage(
// for callers that tuned them. See DEFAULT_IMESSAGE_SEND_TIMEOUT_MS.
const timeoutMs =
opts.timeoutMs ?? account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_SEND_TIMEOUT_MS;
const pendingEchoTtlMs = resolvePendingPersistedEchoTtlMs(timeoutMs);
const region = opts.region?.trim() || account.config.region?.trim() || "US";
const maxBytes =
typeof opts.maxBytes === "number"
@@ -929,6 +954,7 @@ export async function sendMessageIMessage(
audioAsVoice: opts.audioAsVoice,
...(resolvedReplyToId ? { replyToId: resolvedReplyToId } : {}),
echoText: attachmentEchoText,
pendingEchoTtlMs,
runCliJson,
resolveMessageGuidImpl: opts.resolveMessageGuidImpl,
});
@@ -985,6 +1011,8 @@ export async function sendMessageIMessage(
params.to = target.to;
}
const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target });
const client =
opts.client ??
(opts.createClient
@@ -993,8 +1021,17 @@ export async function sendMessageIMessage(
const shouldClose = !opts.client;
let result: Record<string, unknown>;
const sendStartedAtMs = Date.now();
let pendingEchoKey: string | undefined;
try {
try {
if (echoScope) {
pendingEchoKey = rememberPersistedIMessageEcho({
scope: echoScope,
text: echoText,
ttlMs: pendingEchoTtlMs,
pending: true,
});
}
result = await client.request<Record<string, unknown>>("send", params, {
timeoutMs,
});
@@ -1053,7 +1090,6 @@ export async function sendMessageIMessage(
resolveSentMessageGuidImpl: opts.resolveSentMessageGuidImpl,
});
}
const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target });
if (echoScope) {
rememberPersistedIMessageEcho({
scope: echoScope,
@@ -1109,6 +1145,9 @@ export async function sendMessageIMessage(
...(resolvedReplyToId ? { replyToId: resolvedReplyToId } : {}),
}),
};
} catch (error) {
forgetPersistedIMessageEchoKey(pendingEchoKey);
throw error;
} finally {
if (shouldClose) {
await client.stop();

View File

@@ -173,9 +173,13 @@ describe("monitorLineProvider lifecycle", () => {
.mockImplementation(() => innerLineWebhookHandlerMock);
unregisterHttpMock.mockReset();
registerWebhookTargetWithPluginRouteMock.mockReset().mockImplementation((params) => {
const key = params.target.path.startsWith("/")
const withLeadingSlash = params.target.path.startsWith("/")
? params.target.path
: `/${params.target.path}`;
const key =
withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")
? withLeadingSlash.slice(0, -1)
: withLeadingSlash;
const normalizedTarget = { ...params.target, path: key };
const existing = params.targetsByPath.get(key) ?? [];
params.targetsByPath.set(key, [...existing, normalizedTarget]);
@@ -353,6 +357,39 @@ describe("monitorLineProvider lifecycle", () => {
secondMonitor.stop();
});
it("dispatches a signed POST to a configured trailing-slash webhook path", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
webhookPath: "/line/webhook/",
accountId: "default",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
});
const registration = requireWebhookRegistration();
expect(registration.target.path).toBe("/line/webhook");
const route = requireRegisteredRoute();
const payload = JSON.stringify({ events: [{ type: "message" }] });
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
const req = Object.assign(createMockIncomingRequest([payload]), {
method: "POST",
headers: { "x-line-signature": signature },
}) as unknown as IncomingMessage;
const res = createRouteResponse();
await route.handler(req, res);
const bot = createLineBotMock.mock.results[0]?.value as {
handleWebhook: ReturnType<typeof vi.fn>;
};
expect(res.statusCode).toBe(200);
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
monitor.stop();
});
it("acknowledges shared-path POST requests before matched event processing completes", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",

View File

@@ -12,6 +12,7 @@ import {
import {
isRequestBodyLimitError,
normalizePluginHttpPath,
normalizeWebhookPath,
registerWebhookTargetWithPluginRoute,
requestBodyErrorToText,
resolveSingleWebhookTarget,
@@ -337,7 +338,9 @@ export async function monitorLineProvider(
},
});
const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
const normalizedPath = normalizeWebhookPath(
normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook",
);
const createScopedLineWebhookHandler = (target: LineWebhookTarget) =>
createLineNodeWebhookHandler({
channelSecret: target.channelSecret,

View File

@@ -0,0 +1,118 @@
import {
createPluginRegistryFixture,
registerVirtualTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import {
clearEmbeddingProviders,
clearMemoryEmbeddingProviders,
getRegisteredEmbeddingProvider,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
const memoryHostEmbeddingMocks = vi.hoisted(() => ({
createLocalEmbeddingProvider: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({
createLocalEmbeddingProvider: memoryHostEmbeddingMocks.createLocalEmbeddingProvider,
}));
import llamaCppPlugin from "./index.js";
import {
DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
createLlamaCppEmbeddingProvider,
formatLlamaCppSetupError,
} from "./src/embedding-provider.js";
afterEach(() => {
clearEmbeddingProviders();
clearMemoryEmbeddingProviders();
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockReset();
});
describe("llama.cpp provider plugin", () => {
it("registers the local embedding provider through the generic SDK contract", () => {
const { config, registry } = createPluginRegistryFixture();
registerVirtualTestPlugin({
registry,
config,
id: "llama-cpp",
name: "llama.cpp Provider",
contracts: {
embeddingProviders: ["local"],
},
register: llamaCppPlugin.register,
});
const provider = getRegisteredEmbeddingProvider("local");
expect(provider?.ownerPluginId).toBe("llama-cpp");
expect(provider?.adapter).toMatchObject({
id: "local",
defaultModel: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
transport: "local",
});
});
it("adapts the worker-backed local embedding provider", async () => {
const close = vi.fn();
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({
id: "local",
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
maxInputTokens: 2048,
embedQuery: vi.fn(async () => [0.6, 0.8]),
embedBatchInputs: vi.fn(async () => [[0.3, 0.4]]),
embedBatch: vi.fn(async () => [[1, 0]]),
close,
});
const abortController = new AbortController();
const provider = await createLlamaCppEmbeddingProvider(
{
config: {},
provider: "local",
model: "text-embedding-3-small",
},
{ nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" },
);
await expect(provider.embed("hello")).resolves.toEqual([0.6, 0.8]);
await expect(
provider.embedBatch([{ text: "doc" }], { signal: abortController.signal }),
).resolves.toEqual([[0.3, 0.4]]);
await provider.close?.();
expect(provider.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL);
expect(provider.maxInputTokens).toBe(2048);
expect(close).toHaveBeenCalledTimes(1);
expect(memoryHostEmbeddingMocks.createLocalEmbeddingProvider).toHaveBeenCalledWith(
{
config: {},
provider: "local",
fallback: "none",
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
local: {
modelPath: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
},
},
{
nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js",
},
);
const workerProvider =
await memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mock.results[0].value;
expect(workerProvider.embedBatchInputs).toHaveBeenCalledWith([{ text: "doc" }], {
signal: abortController.signal,
});
});
it("formats missing runtime errors with the plugin install command", () => {
const err = Object.assign(new Error("Cannot find package 'node-llama-cpp'"), {
code: "ERR_MODULE_NOT_FOUND",
});
expect(formatLlamaCppSetupError(err)).toContain(
"openclaw plugins install @openclaw/llama-cpp-provider",
);
});
});

View File

@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { llamaCppEmbeddingProviderAdapter } from "./src/embedding-provider.js";
export default definePluginEntry({
id: "llama-cpp",
name: "llama.cpp Provider",
description: "Local GGUF embeddings through node-llama-cpp",
register(api) {
api.registerEmbeddingProvider(llamaCppEmbeddingProviderAdapter);
},
});

1778
extensions/llama-cpp/npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"id": "llama-cpp",
"name": "llama.cpp Provider",
"description": "Local GGUF embeddings through node-llama-cpp.",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"contracts": {
"embeddingProviders": ["local"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

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