Compare commits

..

180 Commits

Author SHA1 Message Date
snowzlmbot
a8904988e1 fix(onboard): refresh provider plugin registry after setup installs (#95792)
Merged via squash.

Prepared head SHA: c99d09f762
Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete

(cherry picked from commit 604d607311)
2026-06-23 08:23:09 +08:00
Vincent Koc
871ba5ad82 fix(agents): bound channel context env 2026-06-23 08:11:33 +08:00
Lanzhi
be5b8434cd feat: pass channel context to exec 2026-06-22 16:59:16 +08:00
Lanzhi
1620d052cf feat(plugin-sdk): add extensible channel identity hook context 2026-06-22 16:59:16 +08:00
Vincent Koc
f9ebb8d91b chore(deadcode): remove unused plugin version validator 2026-06-22 15:37:50 +08:00
zhang-guiping
783d5c19dd fix #89466: [Bug]: Control UI chat input text not cleared after sending (#95503)
Merged via squash.

Prepared head SHA: 32e5fd9cc3
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 15:34:01 +08:00
Peter Steinberger
15a0609a6b test(plugin-sdk): restore private surface budget 2026-06-22 03:30:14 -04:00
Vincent Koc
44ae2fd936 chore(deadcode): remove obsolete cron execution wrapper 2026-06-22 15:25:37 +08:00
ZOOWH
8e76feb482 fix(cron): use main-session systemEvent for silent quick-create preset (#95459)
Merged via squash.

Prepared head SHA: 1e966d4993
Co-authored-by: ZOOWH <265460861+ZOOWH@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 15:23:07 +08:00
Vincent Koc
f1c6057cd7 chore(deadcode): remove unused main-lane queue wrapper 2026-06-22 15:18:05 +08:00
Vincent Koc
c57fee8239 chore(deadcode): remove stale preview helper APIs 2026-06-22 15:07:07 +08:00
Peter Steinberger
23b4f33195 fix(plugin-sdk): keep CLI quoting helper private 2026-06-22 02:59:57 -04:00
Vincent Koc
078044a2cf test(sdk): refresh plugin surface budget 2026-06-22 14:57:53 +08:00
Vincent Koc
8ad231c241 chore(deadcode): remove obsolete session maintenance wrapper 2026-06-22 14:53:17 +08:00
Vincent Koc
04994f1046 test(scripts): normalize RPC RTT path assertions 2026-06-22 14:38:15 +08:00
Vincent Koc
9b4c5822eb test(raft): satisfy gateway lint 2026-06-22 14:36:12 +08:00
Vincent Koc
b39e905b69 fix(raft): support current bridge protocol 2026-06-22 14:36:12 +08:00
Vincent Koc
df6c71736c fix(raft): satisfy channel type contracts 2026-06-22 14:36:12 +08:00
Vincent Koc
2b50bbf152 feat(raft): add wake bridge channel 2026-06-22 14:36:12 +08:00
Wynne668
1bd85e3cc3 fix(openai-completions): seal native reasoning before the answer under /reasoning on (#95283)
* fix(openai-completions): seal native reasoning before the answer

deepseek-style providers stream reasoning via reasoning_content deltas
then switch to the answer via content deltas with no boundary event.
thinking_end was only emitted by the end-of-stream finishBlock loop, so
it landed after the answer's text_delta and channels merged the answer
into the reasoning block.

Seal the open native thinking block when visible text (or a tool call)
begins so thinking_end precedes the answer; tag-based <think> reasoning
is unaffected (closed by the partitioner). finishBlock is now idempotent
so the end-of-stream loop never re-emits thinking_end.

* fix(openai-completions): preserve co-streamed reasoning

* fix(openai-completions): order co-streamed reasoning

* fix(openai-completions): seal co-streamed reasoning

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-22 14:33:44 +08:00
Peter Steinberger
984efdb0b6 fix(discord): finish voice log preview rename 2026-06-22 02:30:08 -04:00
Vincent Koc
abdd81db11 test(qa): include long-context soak scenario 2026-06-22 14:28:04 +08:00
NIO
3cc05d590c fix(docs): show inline read_when hints in docs:list (#95243)
* fix(docs): show inline read_when hints in docs:list

* test(scripts): use shared temp directory helper

* test(scripts): route docs list helper test

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-22 14:14:20 +08:00
Vincent Koc
8246e8dace refactor(plugins): share channel command normalization 2026-06-22 14:10:40 +08:00
Vincent Koc
5df513c895 refactor(channels): share account enabled predicate 2026-06-22 14:08:46 +08:00
Vincent Koc
1529958067 refactor(models): reuse normalized provider lookup 2026-06-22 14:06:50 +08:00
Vincent Koc
7d3bc4d944 fix(ci): bundle test shards and right-size runners
Bundles only isolated low-risk Node shards, keeps stateful suites isolated, and right-sizes Blacksmith runners to reduce runner-registration bursts. Exact-head CI run 27932220655 passed the changed CI planning lanes; its two remaining agent test reds match latest main baseline run 27929063460 and are unrelated to this CI-only diff.
2026-06-22 14:05:40 +08:00
Vincent Koc
5fbb5f75ed refactor(discord): share voice log preview formatting 2026-06-22 14:04:38 +08:00
Vincent Koc
4506d8bad6 fix(qa): surface tool-search bridge results 2026-06-22 08:01:44 +02:00
Vincent Koc
7668ef2d35 refactor(memory): share workspace key normalization 2026-06-22 14:01:23 +08:00
Bryan Tegomoh, MD, MPH
8e4213b1c4 fix(gateway): accept port for health and probe (#94687)
* fix(gateway): accept port for health and probe

* fix(gateway): repair health port override

* fix(gateway): repair health port override

* fix(gateway): accept port for health and probe

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-22 14:00:57 +08:00
Vincent Koc
a0ab5c00a8 test(scripts): harden kitchen sink timeout tests 2026-06-22 13:57:15 +08:00
Ayaan Zaidi
dc09324ec2 fix(telegram): narrow live claim recovery 2026-06-22 11:25:58 +05:30
mikasa0818
3be0fe722a fix(telegram): recover stuck live-owned spool claims
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 11:25:58 +05:30
Vincent Koc
6be98022da fix(qa-matrix): surface gateway stderr in failures 2026-06-22 13:55:49 +08:00
Vincent Koc
cc89e155c3 refactor(imessage): share direct chat normalization 2026-06-22 13:55:36 +08:00
Vincent Koc
0111afe9e2 refactor(cli): share shell argument quoting 2026-06-22 13:54:17 +08:00
Vincent Koc
674b4f3372 refactor(plugins): move owner skills into plugins
Move Canvas, Discord, Slack, voice-call, and WhatsApp skill docs from the root bundled skill tree into their owning plugin packages, with manifest skills declarations and docs/test path updates.

Validation:
- node --import tsx scripts/sync-plugin-versions.ts --check
- node scripts/run-vitest.mjs src/skills/loading/env-path-guidance.test.ts
- autoreview clean
- Crabbox Azure pnpm check:changed run_59ba76511d57 / lease cbx_cbc6750dad72
- wrapper prepare passed pnpm install --frozen-lockfile, pnpm build, and pnpm check before the oversized full local pnpm test was stopped

Follow-up: #95132 remains as the stacked PR for the rest of the starter-skill/Sherpa/ClawHub work.
2026-06-22 13:54:08 +08:00
wangmiao0668000666
4db829646a fix(sdk): type-narrow manifest.files in pack staging root helper (#95465) 2026-06-22 13:52:51 +08:00
Vincent Koc
e046dbb52d test(agents): repair fallback runtime CI coverage 2026-06-22 13:49:43 +08:00
lizeyu-xydt
a964132d80 fix(config): add stdio to McpServerSchema transport union (#95102)
* fix(config): add "stdio" to McpServerSchema transport union

Add z.literal("stdio") to the transport union so that
config validate accepts transport: "stdio" on command-bearing
MCP servers, matching the runtime behavior.

Fixes #95082

* fix(config): align McpServerConfig transport type and add schema coverage

- Widened McpServerConfig.transport to include "stdio"
- Added schema test for command-bearing server with explicit stdio transport
- Added schema test rejecting unsupported transport values

* fix(config): constrain transport stdio to command-bearing MCP servers

* fix(config): align stdio refine with runtime trimmed command check
2026-06-22 13:48:23 +08:00
MaHaoHao-ch
0374892fd8 docs(plugins): document subagent_ended hook fields (#95191)
The Subagents section of docs/plugins/hooks.md listed subagent_ended
but did not describe its payload. Plugin authors reaching for
agentId (as on subagent_spawned) silently got undefined because
PluginHookSubagentEndedEvent uses targetSessionKey as its identity
field and has no agentId/childSessionKey.

Document all fields from PluginHookSubagentEndedEvent and explicitly
note the missing identity fields so handlers can correlate events
correctly.

Closes #95186

Co-authored-by: MaHaoHao-ch <MaHaoHao-ch@users.noreply.github.com>
2026-06-22 13:48:17 +08:00
miorbnli
a2b3aab7b0 fix(ssh): reject hostnames with stray leading or trailing colons in parseSshTarget (#93887)
* fix(ssh): reject hostnames with stray leading or trailing colons in parseSshTarget

parseSshTarget would previously return host values like "host:" or ":22"
when the input had a trailing colon with no port, or a leading colon with
no host. These values flow directly into SSH config HostName fields and the
ssh CLI argv, which causes connections to fail.

Add a guard that rejects host parts starting or ending with ":" before
the existing "-" prefix check. Existing valid inputs ("host", "host:22",
"user@host:22") are unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): validate stray-colon host in explicit-port parse branch

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 13:48:10 +08:00
Yuval Dinodia
2ece2945ae fix(compaction): count user-message image blocks in cut-point estimator (#95128)
estimateTokens charged 4800 chars per image in the toolResult branch but
counted only text in the user branch, so image blocks in recent user turns
scored zero. findCutPoint never reached keepRecentTokens and left the cut at
the earliest point, so image-heavy sessions compacted to a no-op and looped on
context overflow. Fold the per-image accounting into one shared helper used by
both branches.
2026-06-22 13:48:02 +08:00
Vincent Koc
f05fd56d66 fix(googlechat): remove useless test string concatenation 2026-06-22 13:42:34 +08:00
Ayaan Zaidi
a9a75b2b77 test(telegram): tighten spooled drain race proof 2026-06-22 11:08:03 +05:30
weiqinl
e29381a172 fix #86957: drain worker-spooled Telegram updates immediately
Wake the isolated polling drain immediately after a worker-spooled
update is written to channel_ingress_events, instead of waiting for
the next drain interval.

- Add requestImmediateDrain() calls after worker write and spooled message
- Track pending drain requests while drain is active (fix race condition)
- Add regression test for updates arriving during active drain

Fixes #86957.
2026-06-22 11:08:03 +05:30
clawsweeper[bot]
62456d65eb Avoid copying process.env in ingress queue state DB opens (#95278)
* Avoid copying process.env in ingress queue state DB opens

Co-authored-by: kaka-srp <178872848+kaka-srp@users.noreply.github.com>

* Repair #95278: avoid copying process.env in ingress queue DB opens

---------

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: kaka-srp <178872848+kaka-srp@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-22 13:37:51 +08:00
Javier Ailbirt
696c624008 fix(googlechat): sanitize internal tool-trace lines from outbound text (#90684) (#95084)
* fix(googlechat): sanitize internal tool-trace lines from outbound text (#90684)

Google Chat ran only sanitizeForPlainText() on outbound text, so internal
assistant trace lines leaked to users — most visibly the
`⚠️ 🛠️ <command> (agent) failed` banner emitted for benign non-zero shell
exits (e.g. a grep with no match, exit 1). The turn succeeds but the user
gets an alarming "failed" message.

Apply sanitizeAssistantVisibleText() before sanitizeForPlainText() in the
googlechat outbound sanitizeText hook, mirroring Discord and WhatsApp. The
hook runs pre-chunking in the shared deliver path, covering all text sends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(googlechat): sanitize internal tool-trace lines from outbound text (#90684)

---------

Co-authored-by: Claude Code <claude@anthropic.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-22 13:37:06 +08:00
Ayaan Zaidi
b3b8b289dd test(telegram): keep live polling leases protected (#93378) (thanks @mmyzwl) 2026-06-22 11:05:06 +05:30
Vincent Koc
692c5e34f0 refactor(agents): share thinking block predicate 2026-06-22 13:30:22 +08:00
Vincent Koc
536c8a840b refactor(llm): share cache retention resolution 2026-06-22 13:29:23 +08:00
Wynne668
99551c499b fix(skills): point gog brew install at homebrew-core gogcli (#95017) (#95019)
Merged via squash.

Prepared head SHA: a4f489c616
Co-authored-by: ZengWen-DT <290981215+ZengWen-DT@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 13:02:26 +08:00
Vincent Koc
2c3519c1d7 test(agents): cover fallback probe attempt position 2026-06-22 12:58:10 +08:00
Vincent Koc
e114001cca fix(channels): preserve keyed progress state updates 2026-06-22 12:51:33 +08:00
Vincent Koc
3ff59df960 test(agents): cover fallback attempt position 2026-06-22 12:33:38 +08:00
Vincent Koc
a594d2ce73 fix(channels): remove unusable post-final progress override 2026-06-22 12:06:42 +08:00
Vincent Koc
c92f366c14 fix(ci): repair main validation gates 2026-06-22 11:58:27 +08:00
Alex Knight
2b92706dcf feat(mattermost): register /oc_queue as a native slash command
Add oc_queue to DEFAULT_COMMAND_SPECS so the native slash-command registrar exposes /oc_queue, mapped (originalName: queue) to the core /queue directive via the existing trigger-map path. Additive; rides the same registration/callback machinery as the other oc_* commands.
2026-06-22 13:56:40 +10:00
Vincent Koc
a9be81d510 fix(ci): repair baseline lint and test gates 2026-06-22 11:44:21 +08:00
joshavant
97a015bace fix(ios): drop unused bootstrap test hook 2026-06-21 23:40:56 -04:00
joshavant
93c7ec645a fix(ios): remove stale notification authorization seam 2026-06-21 23:40:56 -04:00
joshavant
920bd04e19 fix(ios): make notification guidance suppression a button 2026-06-21 23:40:56 -04:00
joshavant
5ae53cf9fb fix(ios): refine notification permission copy 2026-06-21 23:40:56 -04:00
joshavant
1168ac2fcd fix(ios): update notification permission copy 2026-06-21 23:40:56 -04:00
joshavant
112a0ddaf8 fix(ios): neutralize exec approval prompt copy 2026-06-21 23:40:56 -04:00
joshavant
c3ab1feb61 test(ios): cover notification permission UX 2026-06-21 23:40:56 -04:00
joshavant
0bd2aa8ee0 feat(ios): consolidate notification permission UX 2026-06-21 23:40:56 -04:00
Vincent Koc
7d4d8a7f3d test(agents): add large prompt cache coverage (#95653)
Adds large OpenAI and Anthropic prompt-cache live coverage plus a QA Lab long-context tool-result scenario.

Co-authored-by: vincentkoc <vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 11:39:37 +08:00
Vincent Koc
e9be15ff19 test(scripts): use true package timer overflow inputs 2026-06-22 05:31:53 +02:00
Vincent Koc
05580342f7 fix(agents): remove unused context guard import 2026-06-22 11:18:52 +08:00
Vincent Koc
17dc9902f2 test(scripts): use true overflow timer inputs 2026-06-22 05:11:25 +02:00
ly-wang19
540ec53f99 fix(agents): reject bind specs with extra colon segments (#95572)
Summary:
- The PR changes `parseBindingSpecs` to reject `<channel>:<account>:...` bind specs and adds Matrix parser regression coverage for the malformed and valid forms.
- PR surface: Source +8, Tests +26. Total +34 across 2 files.
- Reproducibility: yes. Source inspection of current main shows `parseBindingSpecs` truncating with `split(":", 2)`, and the PR body provides before/after terminal output from a harness importing the real parser.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): reject bind specs with extra colon segments

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

Prepared head SHA: 3e1af31fc4
Review: https://github.com/openclaw/openclaw/pull/95572#issuecomment-4762320150

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-22 03:07:50 +00:00
Vincent Koc
a182811070 fix(ci): smooth PR runner-registration bursts (#95625)
Merged via squash.

Prepared head SHA: 72b2b00bf8
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-22 10:53:33 +08:00
Vincent Koc
4e2f0157c7 test(scripts): harden kitchen sink timer proof 2026-06-22 04:42:34 +02:00
Vincent Koc
0f64e3c052 fix(scripts): clamp secret proof timers 2026-06-22 04:38:45 +02:00
Vincent Koc
65388233e2 chore(deadcode): remove duplicate session transcript reader 2026-06-22 10:37:42 +08:00
Vincent Koc
044df2516e fix(test): keep Gemini CLI docker lane advisory 2026-06-22 04:32:34 +02:00
Vincent Koc
190ca52882 chore(deadcode): remove stale reply tag helper 2026-06-22 10:31:18 +08:00
Vincent Koc
ad304e790d chore(deadcode): remove unused speech contract registry 2026-06-22 10:19:01 +08:00
Vincent Koc
e913e0739d fix(scripts): clamp openwebui probe timers 2026-06-22 04:11:45 +02:00
Vincent Koc
8e24695a8d chore(deadcode): remove redundant observation aliases 2026-06-22 10:09:48 +08:00
Vincent Koc
b0ecf6e1e7 chore(deadcode): trim private attempt facade 2026-06-22 10:07:58 +08:00
Vincent Koc
dc5c2f6360 fix(core): restore typecheck helpers 2026-06-22 04:06:11 +02:00
Vincent Koc
2405d029d4 test(gateway): classify live provider drift 2026-06-22 04:06:11 +02:00
Vincent Koc
4e96ca0d12 chore(deadcode): prune unused attempt reexports 2026-06-22 10:05:03 +08:00
Vincent Koc
84ccba6b32 chore(deadcode): remove stale embedded-runner reexports 2026-06-22 10:03:04 +08:00
Vincent Koc
d17bb9c3e9 chore(deadcode): collapse session helper barrels 2026-06-22 09:59:31 +08:00
Vincent Koc
2f9107f672 chore(deadcode): remove stale internal reexport shims 2026-06-22 09:57:04 +08:00
Vincent Koc
6328c8637b chore(deadcode): remove stale agent helper reexports 2026-06-22 09:52:40 +08:00
Vincent Koc
3053cbc8a5 Merge branch 'main' of https://github.com/openclaw/openclaw into fix/stable-tool-result-prompt-cache
* 'main' of https://github.com/openclaw/openclaw: (39 commits)
  chore(deadcode): remove stale agent helper reexports
  chore(ui): refresh fa control ui locale
  chore(ui): refresh nl control ui locale
  chore(ui): refresh th control ui locale
  chore(ui): refresh vi control ui locale
  chore(ui): refresh id control ui locale
  chore(ui): refresh pl control ui locale
  chore(ui): refresh uk control ui locale
  chore(ui): refresh tr control ui locale
  chore(ui): refresh it control ui locale
  chore(ui): refresh ar control ui locale
  chore(ui): refresh fr control ui locale
  chore(ui): refresh ko control ui locale
  chore(ui): refresh ja-JP control ui locale
  chore(ui): refresh es control ui locale
  chore(ui): refresh zh-TW control ui locale
  chore(ui): refresh zh-CN control ui locale
  chore(ui): refresh pt-BR control ui locale
  chore(ui): refresh de control ui locale
  fix: preserve fast auto delivery semantics
  ...
2026-06-22 09:48:14 +08:00
Vincent Koc
eb9318e953 chore(deadcode): remove stale agent helper reexports 2026-06-22 09:47:02 +08:00
github-actions[bot]
f80f4a8b95 chore(ui): refresh fa control ui locale 2026-06-22 01:42:45 +00:00
github-actions[bot]
034629404d chore(ui): refresh nl control ui locale 2026-06-22 01:42:28 +00:00
github-actions[bot]
ef67ffd697 chore(ui): refresh th control ui locale 2026-06-22 01:42:04 +00:00
github-actions[bot]
fe524d2a46 chore(ui): refresh vi control ui locale 2026-06-22 01:41:57 +00:00
github-actions[bot]
6c8dcc9d35 chore(ui): refresh id control ui locale 2026-06-22 01:41:53 +00:00
github-actions[bot]
34ab295734 chore(ui): refresh pl control ui locale 2026-06-22 01:41:51 +00:00
github-actions[bot]
a5139a8c5c chore(ui): refresh uk control ui locale 2026-06-22 01:41:20 +00:00
github-actions[bot]
7f99824164 chore(ui): refresh tr control ui locale 2026-06-22 01:41:17 +00:00
github-actions[bot]
cbaeaa8856 chore(ui): refresh it control ui locale 2026-06-22 01:41:15 +00:00
github-actions[bot]
a6cac347b6 chore(ui): refresh ar control ui locale 2026-06-22 01:41:03 +00:00
github-actions[bot]
4d0aec8095 chore(ui): refresh fr control ui locale 2026-06-22 01:40:44 +00:00
github-actions[bot]
fa51a624c0 chore(ui): refresh ko control ui locale 2026-06-22 01:40:40 +00:00
github-actions[bot]
075e328c62 chore(ui): refresh ja-JP control ui locale 2026-06-22 01:40:34 +00:00
github-actions[bot]
0d21d489ab chore(ui): refresh es control ui locale 2026-06-22 01:40:18 +00:00
github-actions[bot]
d8f1000600 chore(ui): refresh zh-TW control ui locale 2026-06-22 01:40:05 +00:00
github-actions[bot]
227b4c81ed chore(ui): refresh zh-CN control ui locale 2026-06-22 01:39:58 +00:00
github-actions[bot]
d28d6c2399 chore(ui): refresh pt-BR control ui locale 2026-06-22 01:39:53 +00:00
github-actions[bot]
d09f728208 chore(ui): refresh de control ui locale 2026-06-22 01:39:40 +00:00
Vincent Koc
9e8ab083dd fix: preserve fast auto delivery semantics 2026-06-22 09:37:11 +08:00
Vincent Koc
6eb72a830e fix: gate fallback fast reset notices 2026-06-22 09:37:11 +08:00
Vincent Koc
6b1eef9959 fix: type fallback state for CLI runs 2026-06-22 09:37:11 +08:00
Vincent Koc
14e448e0e1 fix: carry fast mode fallback state through runners 2026-06-22 09:37:11 +08:00
Vincent Koc
aa3797c8d0 fix: complete fast mode fallback and status wiring 2026-06-22 09:37:10 +08:00
Vincent Koc
93ad397725 fix: preserve normalization and ACP fast mode contracts 2026-06-22 09:37:10 +08:00
Vincent Koc
cf1b6fef44 fix: mark embedded auto fast mode explicitly 2026-06-22 09:37:10 +08:00
Vincent Koc
d990115d19 fix: preserve fast mode across retries 2026-06-22 09:37:10 +08:00
Vincent Koc
8a75c4dd5f fix: preserve repeated channel progress events 2026-06-22 09:37:10 +08:00
Vincent Koc
efd3172662 fix: resolve fallback fast cutoff per model 2026-06-22 09:37:10 +08:00
Vincent Koc
8afc1f770b fix(codex): normalize cleared service tier in bindings 2026-06-22 09:37:10 +08:00
Vincent Koc
77012f9807 fix: clear Codex tier and render auto fast status 2026-06-22 09:37:10 +08:00
Vincent Koc
2732f58215 test: align fast auto forward-port with current fixtures 2026-06-22 09:37:10 +08:00
Vincent Koc
3eeccbe782 fix: remove stale fast auto event import 2026-06-22 09:37:09 +08:00
Vincent Koc
2b75806197 feat: forward-port fast talks auto mode (#85104) 2026-06-22 09:37:09 +08:00
Vincent Koc
adb9abe721 chore(deadcode): collapse stale provider registry views 2026-06-22 09:31:39 +08:00
Vincent Koc
2a6554ac12 fix(scripts): clamp audit request timer 2026-06-22 03:30:19 +02:00
Vincent Koc
4113982fa8 fix(scripts): clamp memory fd repro timers 2026-06-22 03:25:02 +02:00
Vincent Koc
2800ce4e28 chore(deadcode): remove unused plugin contract registry paths 2026-06-22 09:21:24 +08:00
Vincent Koc
45a93b8450 fix(scripts): clamp openwebui probe timer 2026-06-22 03:18:24 +02:00
Vincent Koc
708c1b31e0 fix(agents): preserve transformed tool results 2026-06-22 09:17:50 +08:00
Vincent Koc
3fc1284fe6 test(agents): type complete projection fixtures 2026-06-22 09:17:50 +08:00
Vincent Koc
1a075c375c fix(agents): avoid ambiguous prompt projection reuse 2026-06-22 09:17:50 +08:00
Vincent Koc
95f314e822 fix(agents): stabilize repeated tool result identities 2026-06-22 09:17:50 +08:00
Vincent Koc
efe3cbd695 fix(agents): finish aggregate prompt reduction 2026-06-22 09:17:50 +08:00
Vincent Koc
79fac9fda9 fix(agents): cap new prompt projection output 2026-06-22 09:17:50 +08:00
Vincent Koc
ef6dc8f7e5 fix(agents): count frozen prompt bytes 2026-06-22 09:17:50 +08:00
Vincent Koc
f31306eb4e fix(agents): reproject converted tool results safely 2026-06-22 09:17:50 +08:00
Vincent Koc
db24112617 fix(agents): keep converted tool-result blocks intact 2026-06-22 09:17:50 +08:00
Vincent Koc
4fd19adf25 fix(agents): preserve filtered tool-result blocks 2026-06-22 09:17:50 +08:00
Vincent Koc
62dcc9bc3b fix(agents): freeze prompt projection candidates 2026-06-22 09:17:50 +08:00
Vincent Koc
bda4404f69 fix(agents): reuse memoized prompt projections 2026-06-22 09:17:50 +08:00
Vincent Koc
30925601ae fix(agents): memoize prompt truncation projections 2026-06-22 09:17:49 +08:00
Vincent Koc
a2675756b8 test(agents): stabilize Claude live spawn coverage 2026-06-22 09:16:31 +08:00
Vincent Koc
095a44c8de fix(scripts): clamp run-with-env kill timer 2026-06-22 03:15:26 +02:00
Vincent Koc
29eba5aaef fix(test): clamp qa otel child timer 2026-06-22 03:12:25 +02:00
Vincent Koc
b99812b3b1 fix(scripts): clamp additional boundary timers 2026-06-22 03:08:19 +02:00
Vincent Koc
4f7d1f4977 fix(scripts): clamp boundary artifact timers 2026-06-22 03:05:11 +02:00
Vincent Koc
c310f8cfa4 fix(scripts): clamp package download timers 2026-06-22 03:01:52 +02:00
Vincent Koc
08442c4b38 fix(scripts): clamp boundary check timers 2026-06-22 02:54:30 +02:00
Vincent Koc
fe7b78b05f chore(deadcode): prune stale test hooks 2026-06-22 08:51:49 +08:00
Vincent Koc
dd89898133 fix(scripts): clamp package candidate timers 2026-06-22 02:51:05 +02:00
Vincent Koc
851b65c060 fix(scripts): clamp package docker timers 2026-06-22 02:47:41 +02:00
Vincent Koc
75c6a8fff5 fix(scripts): clamp docker all timers 2026-06-22 02:44:00 +02:00
Vincent Koc
f9fc380e90 fix(scripts): clamp gauntlet command timers 2026-06-22 02:38:04 +02:00
Vincent Koc
8ef73be8e8 fix(scripts): clamp plugin lifecycle timers 2026-06-22 02:33:55 +02:00
Vincent Koc
afadf1f7da chore(deadcode): prune stale codeql paths 2026-06-22 08:33:19 +08:00
Vincent Koc
29185aed68 test(e2e): refresh docker setup fixtures 2026-06-22 02:32:05 +02:00
Vincent Koc
3e5ca880bf fix(scripts): clamp parallels update timeouts 2026-06-22 02:29:26 +02:00
Vincent Koc
a09e1b9aa0 chore(deadcode): prune stale routing config 2026-06-22 08:28:19 +08:00
Vincent Koc
11959ad100 fix(scripts): clamp parallels phase timeouts 2026-06-22 02:24:52 +02:00
snowzlmbot
e37b0f8cd3 fix(telegram): render progress drafts with clean HTML transport
Render Telegram progress draft rows with clean plain previews while preserving Telegram HTML formatting through parse_mode.

The progress HTML path now stays transport-owned, including richMessages=false progress messages, while debug/plain/sanitized text remains readable without raw markup.

Thanks @snowzlmbot!
2026-06-22 05:54:44 +05:30
Vincent Koc
dbb58341b5 chore(deadcode): remove stale vitest coverage excludes 2026-06-22 08:20:03 +08:00
Vincent Koc
790dfb66a8 fix(scripts): clamp parallels host timeouts 2026-06-22 02:18:53 +02:00
Jesse Merhi
4cb94cc2cf fix: keep trusted policies with hook registry (#94545)
* fix: keep trusted policies with hook registry

* fix: compose trusted policies with live hooks

* fix: preserve trusted policy order

* test: update hook runner mock

* fix: keep hook policy registry internal
2026-06-22 10:15:24 +10:00
anagnorisis2peripeteia
298a0cd55f fix(claude-cli): disable unsupported background tools (#95008) (thanks @anagnorisis2peripeteia)
Disable Claude CLI native background Bash and Monitor in OpenClaw-managed print runs, matching the existing fail-closed scheduler-tool behavior. This keeps deferred work on OpenClaw-owned wake paths instead of native Claude callbacks OpenClaw cannot deliver.

Thanks @anagnorisis2peripeteia!
2026-06-22 05:44:48 +05:30
Vincent Koc
c578608b78 fix(scripts): clamp telegram proof timeouts 2026-06-22 02:13:45 +02:00
Vincent Koc
8c0767ffa4 chore(deadcode): remove unused tooling helpers 2026-06-22 08:13:03 +08:00
Omar Shahine
a0714a3d68 fix(imessage): keep split-send coalescing opt-in (#93143)
Merged via squash.

Prepared head SHA: 7fc3eca084
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
2026-06-21 17:12:21 -07:00
Vincent Koc
f719813a7e fix(scripts): clamp kitchen sink timeouts 2026-06-22 02:08:47 +02:00
Vincent Koc
11a3903ede chore(deadcode): remove stale changed-test routes 2026-06-22 08:05:30 +08:00
Vincent Koc
1f6ae32cab fix(process): clamp queue task timeouts 2026-06-22 02:01:39 +02:00
Vincent Koc
880425b03c chore(deadcode): trim unused discord vitest runtime shims 2026-06-22 07:59:00 +08:00
Vincent Koc
4c736df975 fix(agents): clamp session owner wait timeouts 2026-06-22 01:56:37 +02:00
Vincent Koc
6c85b90469 fix(replies): clamp block delivery timeouts 2026-06-22 01:49:55 +02:00
Vincent Koc
ef41560059 chore(deadcode): remove unused vitest helper config 2026-06-22 07:48:28 +08:00
Vincent Koc
88b21fc30b fix(media): clamp image description timeouts 2026-06-22 01:40:44 +02:00
Vincent Koc
4b6182ee2a fix(minimax): clamp vlm request timeouts 2026-06-22 01:31:13 +02:00
Vincent Koc
d43bc3760e chore(deadcode): remove unused test bindings 2026-06-22 07:31:03 +08:00
Vincent Koc
8cd0c11227 fix(clawhub): clamp request timeouts 2026-06-22 01:22:15 +02:00
555 changed files with 13551 additions and 3943 deletions

View File

@@ -22,7 +22,7 @@ paths:
- src/plugins/memory-*.ts
- src/gateway/server-startup-memory.ts
- src/commands/doctor-memory-search.ts
- src/commands/doctor-cron-dreaming-payload-migration.ts
- src/commands/doctor/cron/dreaming-payload-migration.ts
paths-ignore:
- "**/node_modules"

View File

@@ -19,7 +19,6 @@ paths:
- src/plugins/bundled-compat.ts
- src/plugins/bundled-dir.ts
- src/plugins/bundled-plugin-metadata.ts
- src/plugins/bundled-public-surface-runtime-root.ts
- src/plugins/plugin-sdk-dist-alias.ts
- src/plugins/captured-registration.ts
- src/plugins/config-activation-shared.ts
@@ -46,7 +45,6 @@ paths:
- src/plugins/runtime-state.ts
- src/plugins/runtime.ts
- src/plugins/sdk-alias.ts
- src/plugins/source-loader.ts
- src/plugins/types.ts
- src/plugins/validation-diagnostics.ts
- src/plugins/web-provider-public-artifacts*.ts

View File

@@ -51,7 +51,6 @@ paths:
- src/plugins/runtime
- src/plugins/runtime-state.ts
- src/plugins/runtime.ts
- src/plugins/source-loader.ts
- src/plugins/update.ts
- src/plugins/validation-diagnostics.ts
- src/plugin-sdk/*entry*.ts

17
.github/labeler.yml vendored
View File

@@ -41,12 +41,6 @@
- any-glob-to-any-file:
- "extensions/google-meet/**"
- "docs/plugins/google-meet.md"
"plugin: meeting-notes":
- changed-files:
- any-glob-to-any-file:
- "extensions/meeting-notes/**"
- "docs/plugins/meeting-notes.md"
- "src/meeting-notes/**"
"plugin: workboard":
- changed-files:
- any-glob-to-any-file:
@@ -109,6 +103,11 @@
- any-glob-to-any-file:
- "extensions/qqbot/**"
- "docs/channels/qqbot.md"
"channel: raft":
- changed-files:
- any-glob-to-any-file:
- "extensions/raft/**"
- "docs/channels/raft.md"
"channel: qa-channel":
- changed-files:
- any-glob-to-any-file:
@@ -252,12 +251,12 @@
- "src/agents/sandbox*.ts"
- "src/commands/sandbox*.ts"
- "src/cli/sandbox-cli.ts"
- "src/docker-setup.test.ts"
- "src/docker-setup.e2e.test.ts"
- "src/config/**/*sandbox*"
- "docs/cli/sandbox.md"
- "docs/gateway/sandbox*.md"
- "docs/install/docker.md"
- "docs/multi-agent-sandbox-tools.md"
- "docs/tools/multi-agent-sandbox-tools.md"
"agents":
- changed-files:
@@ -270,7 +269,7 @@
- ".github/workflows/opengrep-*.yml"
- ".semgrepignore"
- "docs/cli/security.md"
- "docs/gateway/security.md"
- "docs/gateway/security/**"
- "security/**"
"extensions: admin-http-rpc":

View File

@@ -197,7 +197,7 @@ jobs:
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import {
createNodeTestShards,
createNodeTestShardBundles,
} from "./scripts/lib/ci-node-test-plan.mjs";
import {
createChannelContractTestShards,
@@ -273,7 +273,7 @@ jobs:
}
const nodeTestShards = runNodeFull
? createNodeTestShards({
? createNodeTestShardBundles({
includeReleaseOnlyPluginShards: false,
}).map((shard) => ({
check_name: shard.checkName,
@@ -320,7 +320,14 @@ jobs:
run_checks_windows: runWindows,
checks_windows_matrix: createMatrix(
runWindows
? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }]
? [
{
check_name: "checks-windows-node-test",
runtime: "node",
task: "test",
runner: "blacksmith-8vcpu-windows-2025",
},
]
: [],
),
run_macos_node: runMacos,
@@ -558,7 +565,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
outputs:
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
@@ -819,6 +826,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
@@ -908,6 +916,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
steps:
- name: Checkout
@@ -988,6 +997,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
steps:
- name: Checkout
@@ -1136,10 +1146,11 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 6
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -1248,6 +1259,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 4
matrix:
include:
- check_name: check-guards
@@ -1264,7 +1276,7 @@ jobs:
runner: blacksmith-16vcpu-ubuntu-2404
- check_name: check-dependencies
task: dependencies
runner: blacksmith-8vcpu-ubuntu-2404
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-test-types
task: test-types
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1385,30 +1397,39 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 4
matrix:
include:
- check_name: check-additional-boundaries-a
group: boundaries
boundary_shard: 1/4
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-boundaries-bcd
group: boundaries
boundary_shard: 2/4,3/4,4/4
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-session-accessor-boundary
group: session-accessor-boundary
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-session-transcript-reader-boundary
group: session-transcript-reader-boundary
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-additional-extension-channels
group: extension-channels
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-extension-bundled
group: extension-bundled
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-extension-package-boundary
group: extension-package-boundary
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-runtime-topology-architecture
group: runtime-topology-architecture
runner: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
shell: bash
@@ -1751,7 +1772,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_windows == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-windows-2025') || 'windows-2025') }}
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=8192
@@ -1763,6 +1784,7 @@ jobs:
shell: bash
strategy:
fail-fast: false
max-parallel: 2
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
steps:
- name: Checkout
@@ -2092,6 +2114,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 2
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
steps:
- name: Checkout

View File

@@ -96,7 +96,7 @@ on:
- "src/auto-reply/reply/post-compaction-context.ts"
- "src/auto-reply/reply/queue/**"
- "src/auto-reply/reply/startup-context.ts"
- "src/commands/doctor-cron-dreaming-payload-migration.ts"
- "src/commands/doctor/cron/dreaming-payload-migration.ts"
- "src/commands/doctor-memory-search.ts"
- "src/commands/doctor-session-*.ts"
- "src/commands/session-store-targets.ts"
@@ -257,7 +257,7 @@ jobs:
packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
gateway=true
;;
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
packages/memory-host-sdk/*|src/commands/doctor/cron/dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
memory=true
;;
src/infra/outbound/base-session-key.ts|src/infra/outbound/delivery-queue*.ts|src/infra/outbound/outbound-session.ts|src/infra/outbound/session-binding*.ts|src/infra/outbound/session-context.ts|src/infra/outbound/targets-session.ts)
@@ -295,7 +295,7 @@ jobs:
src/model-catalog/*|src/plugins/*provider*.ts|src/plugins/capability-provider-runtime.ts|src/plugins/compaction-provider.ts|src/plugins/memory-embedding-provider*.ts|src/plugins/memory-embedding-providers*.ts|src/plugins/migration-provider-runtime.ts|src/plugins/synthetic-auth.runtime.ts|src/plugins/web-fetch-providers*.ts|src/plugins/web-search-providers*.ts)
provider=true
;;
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/source-loader.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
plugin=true
;;
packages/plugin-package-contract/*|packages/plugin-sdk/*)

View File

@@ -129,11 +129,28 @@ jobs:
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
fetch_base_ref() {
local ref="$1"
local target="$2"
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
"+${ref}:${target}" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::trusted base fetch for '$ref' timed out on attempt $attempt; retrying"
sleep 5
done
}
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
fetch_base_ref "$BASE_SHA" "refs/remotes/origin/security-base" ||
fetch_base_ref "refs/heads/${BASE_REF}" "refs/remotes/origin/${BASE_REF}"
fi
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then

View File

@@ -54,6 +54,8 @@ struct SettingsProTab: View {
@State var locationStatusText: String?
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State var notificationStatus: SettingsNotificationStatus = .checking
@State var isRequestingNotificationAuthorization = false
@State var showNotificationRelayDisclosure = false
@State var diagnosticsLastRunText = "Not run"
@State var diagnosticsIssueCount: Int?
@State var showTalkIssueDetails = false
@@ -61,15 +63,18 @@ struct SettingsProTab: View {
let initialRoute: SettingsRoute?
let directRoute: SettingsRoute?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let onRouteChange: ((SettingsRoute?) -> Void)?
init(
initialRoute: SettingsRoute? = nil,
directRoute: SettingsRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
onRouteChange: ((SettingsRoute?) -> Void)? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
self.onRouteChange = onRouteChange
}
var body: some View {
@@ -117,6 +122,7 @@ struct SettingsProTab: View {
self.refreshNotificationSettings()
self.applyPendingGatewaySetupLinkIfNeeded()
self.applyInitialRouteIfNeeded()
self.notifyRouteChange()
}
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
@@ -153,6 +159,9 @@ struct SettingsProTab: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
.onChange(of: self.navigationPath) { _, _ in
self.notifyRouteChange()
}
}
private func settingsModalPresentation(_ content: some View) -> some View {
@@ -217,6 +226,19 @@ struct SettingsProTab: View {
} message: {
Text(self.scannerError ?? "")
}
.alert("Enable OpenClaw Hosted Push Relay?", isPresented: self.$showNotificationRelayDisclosure) {
Button("Continue") {
self.requestNotificationAuthorizationFromSettings()
}
Button("Not Now", role: .cancel) {}
} message: {
Text(self.notificationRelayDisclosureMessage)
}
}
func openNotificationsRouteFromApprovals() {
guard self.directRoute == nil else { return }
self.navigationPath = [.notifications]
}
private func applyInitialRouteIfNeeded() {
@@ -225,4 +247,12 @@ struct SettingsProTab: View {
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
private func notifyRouteChange() {
if let directRoute {
self.onRouteChange?(directRoute)
return
}
self.onRouteChange?(self.navigationPath.last)
}
}

View File

@@ -426,15 +426,30 @@ extension SettingsProTab {
self.openNotificationSettings()
return
}
guard self.notificationStatus == .notSet else { return }
if PushBuildConfig.current.usesOpenClawHostedRelay {
self.showNotificationRelayDisclosure = true
return
}
self.requestNotificationAuthorizationFromSettings()
}
func requestNotificationAuthorizationFromSettings() {
guard !self.isRequestingNotificationAuthorization else { return }
self.isRequestingNotificationAuthorization = true
Task {
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
.alert,
.badge,
.sound,
])) ?? false
let settings = await UNUserNotificationCenter.current().notificationSettings()
await MainActor.run {
self.notificationStatus = granted ? .allowed : .notAllowed
self.isRequestingNotificationAuthorization = false
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
guard granted, self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
}
}
}
@@ -661,6 +676,9 @@ extension SettingsProTab {
if self.appModel.isAppleReviewDemoModeEnabled {
return "Live gateway requests are disabled in demo mode."
}
if self.notificationsNeedAttention {
return "Foreground approvals still appear while OpenClaw is connected."
}
return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway."
}
@@ -700,7 +718,19 @@ extension SettingsProTab {
}
var approvalsDetail: String {
self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting"
if self.notificationsNeedAttention {
return self.pendingApproval == nil ? "Notifications off" : "1 waiting, notifications off"
}
return self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting"
}
var notificationsNeedAttention: Bool {
switch self.notificationStatus {
case .allowed, .checking:
false
case .notAllowed, .notSet, .unknown:
true
}
}
var approvalItems: [SettingsApprovalItem] {
@@ -771,4 +801,33 @@ extension SettingsProTab {
var notificationActionText: String {
self.notificationStatus.actionTitle
}
var notificationStatusDetail: String {
switch self.notificationStatus {
case .checking:
"Checking iOS notification permission."
case .allowed:
"OpenClaw can show approval prompts and event alerts when the app is not active."
case .notAllowed:
"Notifications have been denied. Enable them in iOS Settings."
case .notSet:
"Enable notifications to receive approval prompts and event alerts outside the app."
case .unknown:
"OpenClaw cannot determine the current notification permission state."
}
}
var notificationRelayDetail: String {
if PushBuildConfig.current.usesOpenClawHostedRelay {
return """
This build uses OpenClaw's hosted push relay at ios-push-relay.openclaw.ai for notification \
delivery data.
"""
}
return "This build is not configured to use OpenClaw's hosted push relay."
}
var notificationRelayDisclosureMessage: String {
"Enabling this sends delivery data through OpenClaw's hosted push relay."
}
}

View File

@@ -308,15 +308,57 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "checkmark.shield.fill",
title: "Approvals",
detail: self.pendingApproval == nil ? "No gateway actions are waiting for review." :
"Review the pending gateway action.",
value: self.pendingApproval == nil ? "clear" : "1 waiting",
color: self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn)
detail: self.notificationsNeedAttention
? "Out-of-app approval alerts need notification permission."
: (self.pendingApproval == nil ? "No gateway actions are waiting for review." :
"Review the pending gateway action."),
value: self.notificationsNeedAttention
? "Alerts Off"
: (self.pendingApproval == nil ? "clear" : "1 waiting"),
color: self.notificationsNeedAttention ? OpenClawBrand.warn :
(self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn))
if self.notificationsNeedAttention {
self.approvalNotificationsWarningCard
}
self.approvalsReviewCard
}
}
var approvalNotificationsWarningCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: "bell.slash.fill", color: OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 4) {
Text("Notifications are off")
.font(.subheadline.weight(.semibold))
Text(
"""
Enable Notifications to receive approval notifications while OpenClaw is not open.
""")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
if self.directRoute == nil {
Button {
self.openNotificationsRouteFromApprovals()
} label: {
Label("Open Notifications", systemImage: "bell.badge")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var approvalsReviewCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
@@ -490,7 +532,7 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "bell",
title: "Notifications",
detail: "Approvals and event alerts from OpenClaw.",
detail: self.notificationStatusDetail,
value: self.notificationStatusText,
color: self.notificationStatus.color)
@@ -506,10 +548,25 @@ extension SettingsProTab {
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
Text("OpenClaw uses notifications for approval prompts and mirrored event alerts.")
Text(self.notificationStatusDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Divider()
HStack(alignment: .top, spacing: 10) {
Image(systemName: "network")
.font(.caption.weight(.semibold))
.foregroundStyle(OpenClawBrand.accent)
.frame(width: 22, height: 22)
Text(self.notificationRelayDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)

View File

@@ -89,28 +89,48 @@ enum SettingsNotificationStatus: Equatable {
var text: String {
switch self {
case .checking: "Checking"
case .allowed: "Allowed"
case .notAllowed: "Not Allowed"
case .notSet: "Not Set"
case .allowed: "Enabled"
case .notAllowed: "Denied"
case .notSet: "Not Enabled"
case .unknown: "Unknown"
}
}
var actionTitle: String {
switch self {
case .notSet, .checking:
"Request Access"
case .allowed, .notAllowed, .unknown:
"Open System Settings"
case .notSet:
"Enable Notifications"
case .checking:
"Checking"
case .allowed:
"Manage in iOS Settings"
case .notAllowed, .unknown:
"Open iOS Settings"
}
}
var actionIcon: String {
self == .allowed ? "gear" : "bell.badge"
switch self {
case .allowed:
"gear"
case .notAllowed, .unknown:
"gear.badge"
case .checking:
"hourglass"
case .notSet:
"bell.badge"
}
}
var color: Color {
self == .allowed ? OpenClawBrand.ok : .secondary
switch self {
case .allowed:
OpenClawBrand.ok
case .notAllowed, .unknown:
OpenClawBrand.warn
case .checking, .notSet:
.secondary
}
}
var shouldOpenNotificationSettings: Bool {
@@ -121,6 +141,10 @@ enum SettingsNotificationStatus: Equatable {
false
}
}
var allowsNotifications: Bool {
self == .allowed
}
}
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {

View File

@@ -2,11 +2,14 @@ import SwiftUI
private struct ExecApprovalPromptDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
let suppressedApprovalID: String?
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingExecApprovalPrompt {
if let prompt = self.appModel.pendingExecApprovalPrompt,
prompt.id != self.suppressedApprovalID
{
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
@@ -58,7 +61,7 @@ private struct ExecApprovalPromptCard: View {
VStack(alignment: .leading, spacing: 6) {
Text("Exec approval required")
.font(.headline)
Text("OpenClaw opened from a notification. Review this exec request before continuing.")
Text("Review this exec request before continuing. Your decision will be sent back to the gateway.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
@@ -188,7 +191,7 @@ private struct ExecApprovalPromptMetadataRow: View {
}
extension View {
func execApprovalPromptDialog() -> some View {
self.modifier(ExecApprovalPromptDialogModifier())
func execApprovalPromptDialog(suppressedApprovalID: String? = nil) -> some View {
self.modifier(ExecApprovalPromptDialogModifier(suppressedApprovalID: suppressedApprovalID))
}
}

View File

@@ -0,0 +1,102 @@
import SwiftUI
private struct NotificationPermissionGuidanceDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
let openNotifications: (String) -> Void
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingNotificationPermissionGuidancePrompt {
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
NotificationPermissionGuidanceCard(
onOpenNotifications: {
let approvalId = prompt.approvalId
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: false)
self.openNotifications(approvalId)
},
onDismiss: {
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: false)
},
onSuppressFuture: {
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: true)
})
.padding(.horizontal, 20)
.frame(maxWidth: 460)
.transition(.scale(scale: 0.98).combined(with: .opacity))
}
.zIndex(2)
.id(prompt.id)
}
}
.animation(
.easeInOut(duration: 0.18),
value: self.appModel.pendingNotificationPermissionGuidancePrompt?.id)
}
}
private struct NotificationPermissionGuidanceCard: View {
let onOpenNotifications: () -> Void
let onDismiss: () -> Void
let onSuppressFuture: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Notifications are off")
.font(.headline)
Text(
"""
Exec approvals can only be reviewed while OpenClaw is open and connected.
Enable Notifications to receive approval notifications while OpenClaw is not open.
""")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(spacing: 10) {
Button {
self.onOpenNotifications()
} label: {
Text("Open Notifications Settings")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button(role: .cancel) {
self.onDismiss()
} label: {
Text("Not Now")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
self.onSuppressFuture()
} label: {
Text("Don't show again")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.padding(18)
.proPanelSurface(tint: OpenClawBrand.warn, radius: 20, isProminent: true)
}
}
extension View {
func notificationPermissionGuidanceDialog(openNotifications: @escaping (String) -> Void) -> some View {
self.modifier(NotificationPermissionGuidanceDialogModifier(openNotifications: openNotifications))
}
}

View File

@@ -87,6 +87,11 @@ final class NodeAppModel {
}
}
struct NotificationPermissionGuidancePrompt: Identifiable, Equatable {
let id = UUID()
let approvalId: String
}
private enum ExecApprovalResolutionOutcome {
case resolved
case stale
@@ -100,6 +105,8 @@ final class NodeAppModel {
}
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
private nonisolated static let execApprovalNotificationGuidanceSuppressedKey =
"notifications.execApprovalGuidance.suppressed"
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
@@ -160,6 +167,7 @@ final class NodeAppModel {
private(set) var pendingExecApprovalPromptResolving: Bool = false
private(set) var pendingExecApprovalPromptErrorText: String?
private var pendingExecApprovalPromptRequestGeneration: Int = 0
private(set) var pendingNotificationPermissionGuidancePrompt: NotificationPermissionGuidancePrompt?
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private var lastAgentDeepLinkPromptAt: Date = .distantPast
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
@@ -921,6 +929,7 @@ final class NodeAppModel {
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
case ExecApprovalNotificationBridge.requestedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.presentNotificationPermissionGuidanceForExecApprovalIfNeeded(approvalId: approvalId)
await self.presentExecApprovalNotificationPrompt(
ExecApprovalNotificationPrompt(approvalId: approvalId))
case ExecApprovalNotificationBridge.resolvedKind:
@@ -1359,8 +1368,8 @@ final class NodeAppModel {
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification"))
}
let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else {
let status = await self.notificationAuthorizationStatus()
guard Self.isNotificationAuthorizationAllowed(status) else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -1412,9 +1421,18 @@ final class NodeAppModel {
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text"))
}
let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
let shouldSpeak = params.speak ?? true
let status = await self.notificationAuthorizationStatus()
let notificationsAllowed = Self.isNotificationAuthorizationAllowed(status)
if !notificationsAllowed, !shouldSpeak {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
}
let messageId = UUID().uuidString
if finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral {
if notificationsAllowed {
let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
let content = UNMutableNotificationContent()
content.title = "OpenClaw"
@@ -1435,7 +1453,7 @@ final class NodeAppModel {
}
}
if params.speak ?? true {
if shouldSpeak {
let toSpeak = text
Task { @MainActor in
try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak)
@@ -1447,26 +1465,6 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
}
private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus {
let status = await self.notificationAuthorizationStatus()
guard status == .notDetermined else { return status }
// Avoid hanging invoke requests if the permission prompt is never answered.
_ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
}
let updatedStatus = await self.notificationAuthorizationStatus()
if Self.isNotificationAuthorizationAllowed(updatedStatus) {
// Refresh APNs registration immediately after the first permission grant so the
// gateway can receive a push registration without requiring an app relaunch.
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return updatedStatus
}
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in
await notificationCenter.authorizationStatus()
@@ -1490,6 +1488,29 @@ final class NodeAppModel {
}
}
private func presentNotificationPermissionGuidanceForExecApprovalIfNeeded(approvalId: String) async {
guard !self.execApprovalNotificationGuidanceSuppressed else { return }
let status = await self.notificationAuthorizationStatus()
guard !Self.isNotificationAuthorizationAllowed(status) else { return }
self.pendingNotificationPermissionGuidancePrompt =
NotificationPermissionGuidancePrompt(approvalId: approvalId)
}
var execApprovalNotificationGuidanceSuppressed: Bool {
UserDefaults.standard.bool(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
func dismissNotificationPermissionGuidancePrompt(suppressFuture: Bool) {
if suppressFuture {
UserDefaults.standard.set(true, forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
self.pendingNotificationPermissionGuidancePrompt = nil
}
func resetExecApprovalNotificationGuidanceSuppression() {
UserDefaults.standard.removeObject(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
private func runNotificationCall<T: Sendable>(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
@@ -2362,10 +2383,6 @@ extension NodeAppModel {
nodeOptions: nodeOptions,
sessionBox: sessionBox)
}
// QR bootstrap onboarding should surface the system notification permission
// prompt immediately so visible APNs alerts work without a second manual step.
_ = await self.requestNotificationAuthorizationIfNeeded()
}
private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
@@ -3943,11 +3960,15 @@ extension NodeAppModel {
let hadWatchPrompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] != nil
let hadPendingPrompt = self.pendingExecApprovalPrompt?.id == normalizedApprovalID
let hadPendingRecoveryID = self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID)
guard hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID else {
let hadGuidancePrompt = self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID
let hadApprovalSurface = hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID
guard hadApprovalSurface || hadGuidancePrompt else {
return
}
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
if hadApprovalSurface {
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
}
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
}
@@ -4428,10 +4449,17 @@ extension NodeAppModel {
private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
self.clearNotificationPermissionGuidancePromptIfMatches(normalizedApprovalID)
guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return }
self.dismissPendingExecApprovalPrompt()
}
private func clearNotificationPermissionGuidancePromptIfMatches(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID else { return }
self.pendingNotificationPermissionGuidancePrompt = nil
}
private nonisolated static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
if gatewayError.code != "INVALID_REQUEST" {
@@ -5137,6 +5165,20 @@ extension NodeAppModel {
self.pendingExecApprovalPrompt
}
func _test_pendingNotificationPermissionGuidancePrompt() -> NotificationPermissionGuidancePrompt? {
self.pendingNotificationPermissionGuidancePrompt
}
func _debug_presentNotificationPermissionGuidancePromptForScreenshot() {
self.resetExecApprovalNotificationGuidanceSuppression()
self.pendingNotificationPermissionGuidancePrompt =
NotificationPermissionGuidancePrompt(approvalId: "screenshot-exec-approval")
}
func _test_resetExecApprovalNotificationGuidanceSuppression() {
self.resetExecApprovalNotificationGuidanceSuppression()
}
func _test_recordPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
self.appendPendingWatchExecApprovalRecoveryID(approvalId)
}
@@ -5270,24 +5312,6 @@ extension NodeAppModel {
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: URL(string: "wss://gateway.example")!,
stableID: "test-gateway",
token: nil,
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: nil),
sessionBox: nil)
}
}
#endif
// swiftlint:enable type_body_length file_length

View File

@@ -409,7 +409,7 @@ enum WatchPromptNotificationBridge {
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty || !body.isEmpty else { return }
guard await self.requestNotificationAuthorizationIfNeeded() else { return }
guard await self.isNotificationAuthorizationAllowed() else { return }
let normalizedActions = (params.actions ?? []).compactMap { action -> OpenClawWatchAction? in
let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -516,29 +516,10 @@ enum WatchPromptNotificationBridge {
}
}
private static func requestNotificationAuthorizationIfNeeded() async -> Bool {
private static func isNotificationAuthorizationAllowed() async -> Bool {
let center = UNUserNotificationCenter.current()
let status = await self.notificationAuthorizationStatus(center: center)
switch status {
case .authorized, .provisional, .ephemeral:
return true
case .notDetermined:
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if !granted { return false }
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
if self.isAuthorizationStatusAllowed(updatedStatus) {
// Refresh APNs registration immediately after the first permission grant so the
// gateway can receive a push registration without requiring an app relaunch.
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return self.isAuthorizationStatusAllowed(updatedStatus)
case .denied:
return false
@unknown default:
return false
}
return self.isAuthorizationStatusAllowed(status)
}
private static func isAuthorizationStatusAllowed(_ status: UNAuthorizationStatus) -> Bool {
@@ -635,6 +616,9 @@ struct OpenClawApp: App {
UserDefaults.standard.set(true, forKey: "gateway.hasConnectedOnce")
UserDefaults.standard.set(true, forKey: "onboarding.quickSetupDismissed")
appModel.enterScreenshotFixtureMode()
if Self.screenshotNotificationGuidanceEnabled {
appModel._debug_presentNotificationPermissionGuidancePromptForScreenshot()
}
}
#endif
OpenClawAppModelRegistry.appModel = appModel
@@ -686,6 +670,14 @@ struct OpenClawApp: App {
#endif
}
private static var screenshotNotificationGuidanceEnabled: Bool {
#if DEBUG
ProcessInfo.processInfo.arguments.contains("--openclaw-screenshot-notification-guidance")
#else
false
#endif
}
@MainActor
private func applyAppearancePreference() {
let style = self.appearancePreference.userInterfaceStyle

View File

@@ -22,6 +22,20 @@ struct PushBuildConfig {
let apnsEnvironment: PushAPNsEnvironment
static let current = PushBuildConfig()
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
var usesOpenClawHostedRelay: Bool {
guard self.transport == .relay, self.distribution == .official else { return false }
guard let relayBaseURL = self.relayBaseURL,
let components = URLComponents(url: relayBaseURL, resolvingAgainstBaseURL: false)
else {
return false
}
return components.scheme?.lowercased() == "https"
&& components.host?.lowercased() == Self.openClawHostedRelayHost
&& components.user == nil
&& components.password == nil
}
init(bundle: Bundle = .main) {
self.transport = Self.readEnum(

View File

@@ -24,6 +24,8 @@ struct RootTabs: View {
AppAppearancePreference.system.rawValue
@State private var selectedTab: AppTab = Self.initialTab
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
@State private var selectedSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute
@State private var selectedSettingsRouteRequestID: Int = 0
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
@State private var isSidebarDrawerLayout: Bool = false
@@ -39,6 +41,7 @@ struct RootTabs: View {
@State private var didApplyInitialAppearance: Bool = false
@State private var didApplyInitialChatSession: Bool = false
@State private var handledGatewaySetupRequestID: Int = 0
@State private var suppressedExecApprovalPromptIDForNotificationSettings: String?
private static var initialTab: AppTab {
let arguments = ProcessInfo.processInfo.arguments
@@ -161,8 +164,10 @@ struct RootTabs: View {
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
SettingsProTab(
initialRoute: self.selectedSettingsRoute,
onRouteChange: self.handleSettingsRouteChange)
.id(self.settingsTabViewID)
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(AppTab.settings)
}
@@ -235,7 +240,7 @@ struct RootTabs: View {
private var sidebarDetailShell: some View {
self.sidebarDetail
.id(self.selectedSidebarDestination.id)
.id(self.sidebarDetailShellID)
}
private var sidebarColumn: some View {
@@ -463,11 +468,21 @@ struct RootTabs: View {
headerLeadingAction: self.sidebarHeaderLeadingAction,
gatewayAction: { self.selectSidebarDestination(.gateway) })
case .settings:
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
if let selectedSettingsRoute {
SettingsProTab(
directRoute: selectedSettingsRoute,
headerLeadingAction: self.sidebarHeaderLeadingAction,
onRouteChange: self.handleSettingsRouteChange)
} else {
SettingsProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
onRouteChange: self.handleSettingsRouteChange)
}
case .gateway:
SettingsProTab(
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
headerLeadingAction: self.sidebarHeaderLeadingAction)
directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway,
headerLeadingAction: self.sidebarHeaderLeadingAction,
onRouteChange: self.handleSettingsRouteChange)
}
}
@@ -492,6 +507,21 @@ struct RootTabs: View {
return UIDevice.current.userInterfaceIdiom
}
private var sidebarDetailShellID: String {
let routeID = self.selectedSettingsRoute.map { "\($0)" } ?? "root"
return "\(self.selectedSidebarDestination.id):\(routeID):\(self.selectedSettingsRouteRequestID)"
}
private var settingsTabViewID: String {
let routeID = self.selectedSettingsRoute.map { "\($0)" } ?? "settings"
return "\(routeID):\(self.selectedSettingsRouteRequestID)"
}
private var activeExecApprovalPromptSuppressionID: String? {
guard self.selectedTab == .settings, self.selectedSettingsRoute == .notifications else { return nil }
return self.suppressedExecApprovalPromptIDForNotificationSettings
}
private var shouldCollapseSidebarAfterSelection: Bool {
Self.shouldCollapseSidebarAfterSelection(
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
@@ -705,6 +735,11 @@ struct RootTabs: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
}
.onChange(of: self.appModel.pendingExecApprovalPrompt?.id) { _, newValue in
if newValue != self.suppressedExecApprovalPromptIDForNotificationSettings {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
}
}
}
private func rootPresentation(_ content: some View) -> some View {
@@ -742,7 +777,12 @@ struct RootTabs: View {
}
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog()
.execApprovalPromptDialog(
suppressedApprovalID: self.activeExecApprovalPromptSuppressionID)
.notificationPermissionGuidanceDialog(openNotifications: { approvalId in
self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId
self.selectSettingsRoute(.notifications)
})
}
private var appearancePreference: AppAppearancePreference {
@@ -874,9 +914,15 @@ struct RootTabs: View {
private func homeCanvasName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
}
}
extension RootTabs {
private func selectSidebarDestination(_ destination: SidebarDestination) {
if destination.settingsRoute != .notifications {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
}
self.selectedSidebarDestination = destination
self.selectedSettingsRoute = destination.settingsRoute
self.selectedTab = destination.appTab
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
withAnimation(.easeInOut(duration: 0.22)) {
@@ -884,6 +930,31 @@ struct RootTabs: View {
}
}
private func selectSettingsRoute(_ route: SettingsRoute) {
if route != .notifications {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
}
self.selectedSettingsRoute = route
self.selectedSettingsRouteRequestID &+= 1
self.selectedSidebarDestination = .settings
self.selectedTab = .settings
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(false)
}
}
private func handleSettingsRouteChange(_ route: SettingsRoute?) {
guard route != .notifications else { return }
if route == nil {
self.selectedSettingsRoute = nil
if self.selectedTab == .settings {
self.selectedSidebarDestination = .settings
}
}
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
}
private func showSidebar() {
self.sidebarVisibilityUserOverridden = true
withAnimation(.easeInOut(duration: 0.22)) {

View File

@@ -16,7 +16,6 @@ enum NotificationAuthorizationStatus {
protocol NotificationCentering: Sendable {
func authorizationStatus() async -> NotificationAuthorizationStatus
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func add(_ request: UNNotificationRequest) async throws
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async
@@ -48,10 +47,6 @@ struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
}
}
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
try await self.center.requestAuthorization(options: options)
}
func add(_ request: UNNotificationRequest) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
self.center.add(request) { error in

View File

@@ -50,6 +50,7 @@ Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Gateway/GatewayHealthMonitor.swift
Sources/Gateway/GatewayProblemView.swift
Sources/Gateway/GatewayQuickSetupSheet.swift
Sources/Gateway/NotificationPermissionGuidanceDialog.swift
Sources/Gateway/GatewayServiceResolver.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/GatewayTrustPromptAlert.swift

View File

@@ -14,10 +14,6 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
self.authorization
}
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
true
}
func add(_ request: UNNotificationRequest) async throws {
self.addedRequests.append(request)
}

View File

@@ -200,25 +200,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
var status: NotificationAuthorizationStatus = .notDetermined
var requestAuthorizationResult = false
var requestAuthorizationCalls = 0
var addCalls = 0
func authorizationStatus() async -> NotificationAuthorizationStatus {
self.status
}
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
self.requestAuthorizationCalls += 1
if self.requestAuthorizationResult {
self.status = .authorized
} else {
self.status = .denied
}
return self.requestAuthorizationResult
func add(_: UNNotificationRequest) async throws {
self.addCalls += 1
}
func add(_: UNNotificationRequest) async throws {}
func removePendingNotificationRequests(withIdentifiers _: [String]) async {}
func removeDeliveredNotifications(withIdentifiers _: [String]) async {}
@@ -1230,13 +1221,65 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
hasStoredOperatorToken: false))
}
@Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async {
@Test @MainActor func operatorGatewayRequestedEventShowsNotificationGuidanceWhenNotificationsOff() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined
let appModel = NodeAppModel(notificationCenter: center)
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
await appModel._test_handleSuccessfulBootstrapGatewayOnboarding()
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.requestedKind,
payload: AnyCodable(["id": "approval-notifications-off"]),
seq: nil,
stateversion: nil))
#expect(center.requestAuthorizationCalls == 1)
let prompt = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt())
#expect(prompt.approvalId == "approval-notifications-off")
}
@Test @MainActor func suppressedOperatorGatewayRequestedEventDoesNotShowNotificationGuidance() async {
let center = MockBootstrapNotificationCenter()
center.status = .denied
let appModel = NodeAppModel(notificationCenter: center)
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
appModel.dismissNotificationPermissionGuidancePrompt(suppressFuture: true)
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.requestedKind,
payload: AnyCodable(["id": "approval-suppressed"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil)
}
@Test @MainActor func operatorGatewayResolvedEventClearsNotificationGuidancePrompt() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .denied
let appModel = NodeAppModel(notificationCenter: center)
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.requestedKind,
payload: AnyCodable(["id": "approval-guidance-resolved"]),
seq: nil,
stateversion: nil))
_ = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt())
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.resolvedKind,
payload: AnyCodable(["id": "approval-guidance-resolved"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil)
}
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() throws {
@@ -1298,6 +1341,78 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(res.error?.message.contains("CAMERA_DISABLED") == true)
}
@Test @MainActor func systemNotifyReturnsUnavailableWhenNotificationsOff() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request")
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "notify-off",
command: OpenClawSystemCommand.notify.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.code == .unavailable)
#expect(res.error?.message == "NOT_AUTHORIZED: notifications")
#expect(center.addCalls == 0)
}
@Test @MainActor func systemNotifySchedulesWhenNotificationsAreAlreadyAllowed() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request")
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "notify-on",
command: OpenClawSystemCommand.notify.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok)
#expect(center.addCalls == 1)
}
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawChatPushParams(text: "Build finished", speak: false)
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "chat-push-off",
command: OpenClawChatCommand.push.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.code == .unavailable)
#expect(res.error?.message == "NOT_AUTHORIZED: notifications")
#expect(center.addCalls == 0)
}
@Test @MainActor func chatPushSchedulesWhenNotificationsAreAlreadyAllowed() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawChatPushParams(text: "Build finished", speak: false)
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "chat-push-on",
command: OpenClawChatCommand.push.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok)
#expect(center.addCalls == 1)
}
@Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async {
let appModel = NodeAppModel()
let params = OpenClawScreenRecordParams(format: "gif")

View File

@@ -1,8 +1,8 @@
import Foundation
import Testing
@Suite struct RootTabsSourceGuardTests {
@Test func hiddenSidebarRevealUsesDestinationHeaderWithoutReservedRail() throws {
struct RootTabsSourceGuardTests {
@Test func `hidden sidebar reveal uses destination header without reserved rail`() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let componentSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
@@ -38,7 +38,7 @@ import Testing
#expect(!source.contains("shouldShowOverviewHeaderSidebarReveal"))
}
@Test func iPadSplitUsesSlidingSidebarWhilePortraitKeepsDrawerOverlay() throws {
@Test func `i pad split uses sliding sidebar while portrait keeps drawer overlay`() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let splitContent = try Self.extract(
source,
@@ -67,7 +67,7 @@ import Testing
#expect(!drawerContent.contains("NavigationSplitView"))
}
@Test func sidebarKeepsNavigationModelDestinationOnly() throws {
@Test func `sidebar keeps navigation model destination only`() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
let sidebarColumn = try Self.extract(
@@ -114,7 +114,7 @@ import Testing
#expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])"))
}
@Test func sidebarRoutesUseDestinationHeadersInsteadOfRepeatedProductBranding() throws {
@Test func `sidebar routes use destination headers instead of repeated product branding`() throws {
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
@@ -148,7 +148,7 @@ import Testing
#expect(!docsSource.contains("Text(\"OpenClaw Docs\")"))
}
@Test func agentsDirectRouteKeepsSingleSidebarControl() throws {
@Test func `agents direct route keeps single sidebar control`() throws {
let source = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
@@ -165,7 +165,7 @@ import Testing
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
}
@Test func routedHeadersUseSharedAdaptiveLayout() throws {
@Test func `routed headers use shared adaptive layout`() throws {
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
@@ -187,7 +187,7 @@ import Testing
#expect(settingsSource.contains("OpenClawAdaptiveHeaderRow("))
}
@Test func phoneHubKeepsDocsAsDestinationOnly() throws {
@Test func `phone hub keeps docs as destination only`() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("case .docs:"))
@@ -198,7 +198,7 @@ import Testing
#expect(!source.contains("https://docs.openclaw.ai"))
}
@Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws {
@Test func `root shell preview matrix covers phone and I pad states`() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\n \"Shell iPhone portrait\""))
@@ -211,7 +211,7 @@ import Testing
#expect(source.contains("#Preview(\n \"Shell iPad gateway error\""))
}
@Test func sharedChatPreviewMatrixCoversConnectionStates() throws {
@Test func `shared chat preview matrix covers connection states`() throws {
let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Chat connected\")"))
@@ -226,7 +226,7 @@ import Testing
#expect(source.contains("Gateway not connected. Check Tailscale and retry."))
}
@Test func phoneHubKeepsContentAboveFloatingTabBar() throws {
@Test func `phone hub keeps content above floating tab bar`() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)"))
@@ -235,7 +235,7 @@ import Testing
#expect(!source.contains("bottomTabBarClearance"))
}
@Test func phoneHubHeaderStaysTaskFirst() throws {
@Test func `phone hub header stays task first`() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("private var gatewayActionRow: some View"))
@@ -253,7 +253,7 @@ import Testing
#expect(!source.contains("private func metric(label:"))
}
@Test func workboardUsesRealGatewayMethods() throws {
@Test func `workboard uses real gateway methods`() throws {
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
#expect(source.contains("workboard.cards.list"))
@@ -268,7 +268,7 @@ import Testing
#expect(!source.contains("Multi-column queue control"))
}
@Test func workboardCreateActionSurfacesUnavailableReasons() throws {
@Test func `workboard create action surfaces unavailable reasons`() throws {
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
let createFunction = try Self.extract(
source,
@@ -295,7 +295,7 @@ import Testing
#expect(createFunction.contains("return true"))
}
@Test func taskScopeControlsSendRealGatewayParams() throws {
@Test func `task scope controls send real gateway params`() throws {
let source = try Self.iPadTaskFeatureScreensSource()
#expect(source.contains("private var boardScopeMenu: some View"))
@@ -314,7 +314,7 @@ import Testing
"params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self"))
}
@Test func compactTaskRowsKeepPhoneNativeActions() throws {
@Test func `compact task rows keep phone native actions`() throws {
let source = try Self.iPadTaskFeatureScreensSource()
let compactControls = try Self.extract(
source,
@@ -348,7 +348,7 @@ import Testing
#expect(compactControls.contains("Label(\"Dispatch\""))
}
@Test func skillWorkshopUsesKanbanLanesOnWideIPad() throws {
@Test func `skill workshop uses kanban lanes on wide I pad`() throws {
let source = try String(contentsOf: Self.iPadSkillWorkshopScreenSourceURL(), encoding: .utf8)
let previewSource = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
let content = try Self.extract(
@@ -376,7 +376,7 @@ import Testing
#expect(previewSource.contains("status: \"manual_QA\""))
}
@Test func compactTaskRowsHavePopulatedPhonePreviews() throws {
@Test func `compact task rows have populated phone previews`() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Workboard phone queue rows\")"))
@@ -387,7 +387,7 @@ import Testing
#expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals"))
}
@Test func taskScreenPreviewMatricesCoverPrimaryStates() throws {
@Test func `task screen preview matrices cover primary states`() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Workboard states\")"))
@@ -412,7 +412,7 @@ import Testing
#expect(source.contains("\"manual_QA\""))
}
@Test func activityPreviewMatrixCoversConnectionStates() throws {
@Test func `activity preview matrix covers connection states`() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Activity states\")"))
@@ -426,7 +426,7 @@ import Testing
#expect(source.contains("title: \"Loading sessions\""))
}
@Test func routedFeatureScreensReuseSharedProComponents() throws {
@Test func `routed feature screens reuse shared pro components`() throws {
let source = try Self.iPadTaskFeatureScreensSource()
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
@@ -445,20 +445,22 @@ import Testing
#expect(componentsSource.contains("struct ProStatusRow"))
}
@Test func activityScreenStaysSplitFromTaskFeatureScreens() throws {
@Test func `activity screen stays split from task feature screens`() throws {
let taskSource = try Self.iPadTaskFeatureScreensSource()
let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8)
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
#expect(activitySource.contains("struct IPadActivityScreen: View"))
#expect(activitySource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
#expect(activitySource.contains("self.appModel.makeChatTransport()"))
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
#expect(activitySource.contains("IPadSidebarScreenChrome("))
#expect(!taskSource.contains("struct IPadActivityScreen"))
#expect(!taskSource.contains("import OpenClawChatUI"))
#expect(projectSource.contains("IPadActivityScreen.swift in Sources"))
}
@Test func routedFeatureChromeStaysSplitFromTaskFeatureScreens() throws {
@Test func `routed feature chrome stays split from task feature screens`() throws {
let taskSource = try Self.iPadTaskFeatureScreensSource()
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
@@ -470,7 +472,7 @@ import Testing
#expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources"))
}
@Test func routedFeatureChromeKeepsGatewayPillActionable() throws {
@Test func `routed feature chrome keeps gateway pill actionable`() throws {
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let featureSource = try Self.iPadTaskFeatureScreensSource()
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
@@ -486,14 +488,18 @@ import Testing
.count == 1)
}
@Test func routedGatewayPillsOpenGatewaySettings() throws {
@Test func `routed gateway pills open gateway settings`() throws {
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let agentSource = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let settingsTabSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let notificationGuidanceSource = try String(
contentsOf: Self.notificationPermissionGuidanceDialogSourceURL(),
encoding: .utf8)
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
@@ -512,15 +518,39 @@ import Testing
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
#expect(rootSource.contains("case .settings:"))
#expect(rootSource.contains("SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)"))
#expect(rootSource.contains("directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway"))
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
#expect(rootSource
.matches(of: /SettingsProTab\(\s*headerLeadingAction: self\.sidebarHeaderLeadingAction,/)
.count >= 1)
#expect(rootSource
.contains(
"directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway"))
#expect(rootSource.matches(of: /SettingsProTab\(\s*initialRoute: self\.selectedSettingsRoute,/).count == 1)
#expect(rootSource.contains(".id(self.settingsTabViewID)"))
#expect(rootSource.contains("@State private var selectedSettingsRouteRequestID: Int = 0"))
#expect(rootSource.contains("self.selectedSettingsRouteRequestID &+= 1"))
#expect(rootSource.contains("@State private var suppressedExecApprovalPromptIDForNotificationSettings"))
#expect(rootSource.contains("private var activeExecApprovalPromptSuppressionID: String?"))
#expect(rootSource.contains("suppressedApprovalID: self.activeExecApprovalPromptSuppressionID"))
#expect(rootSource.contains("if destination.settingsRoute != .notifications"))
#expect(rootSource.contains("if route != .notifications"))
#expect(rootSource.contains("if route == nil"))
#expect(rootSource.contains("self.selectedSettingsRoute = nil"))
#expect(rootSource.contains("self.selectedSidebarDestination = .settings"))
#expect(rootSource.contains("self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId"))
#expect(rootSource.contains("onRouteChange: self.handleSettingsRouteChange"))
#expect(rootSource.contains("private func handleSettingsRouteChange(_ route: SettingsRoute?)"))
#expect(settingsTabSource.contains("let onRouteChange: ((SettingsRoute?) -> Void)?"))
#expect(settingsTabSource.contains("self.onRouteChange?(self.navigationPath.last)"))
#expect(notificationGuidanceSource.contains("onSuppressFuture"))
#expect(notificationGuidanceSource.contains("suppressFuture: true"))
#expect(notificationGuidanceSource.contains("Text(\"Don't show again\")"))
#expect(rootSource.contains("private func selectSettingsRoute(_ route: SettingsRoute)"))
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
#expect(settingsSource.contains("route: .channels"))
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
@@ -566,7 +596,7 @@ import Testing
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
}
@Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws {
@Test func `gateway settings preview matrix covers primary states`() throws {
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
#expect(supportSource.contains("#Preview(\"Gateway settings states\")"))
@@ -585,12 +615,15 @@ import Testing
#expect(supportSource.contains("self.previewButton(\"Diagnose\""))
}
@Test func nativeChatUsesGatewayTransport() throws {
@Test func `native chat uses gateway transport`() throws {
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
let settingsSectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
#expect(chatSource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
#expect(channelsSource.contains("Message routing and external channel clients."))
#expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2)
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
#expect(settingsSectionsSource.contains("Message routing and external channel clients."))
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
#expect(channelsSource.contains("label: \"ClickClack\""))
#expect(channelsSource.contains("Self-hosted chat bot routing."))
@@ -603,6 +636,13 @@ import Testing
.appendingPathComponent("Sources/RootTabs.swift")
}
private static func nodeAppModelSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Model/NodeAppModel.swift")
}
private static func phoneHubSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -746,6 +786,13 @@ import Testing
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
}
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Gateway/NotificationPermissionGuidanceDialog.swift")
}
private static func settingsProTabActionsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()

View File

@@ -69,6 +69,17 @@
"fileName"
]
},
"api": {
"emoji": "🌐",
"title": "API",
"detailKeys": [
"url",
"endpoint",
"path",
"method",
"name"
]
},
"browser": {
"emoji": "🌐",
"title": "Browser",

View File

@@ -7277,7 +7277,9 @@ public struct ChatSendParams: Codable, Sendable {
public let sessionid: String?
public let message: String
public let thinking: String?
public let fastmode: Bool?
public let fastmodevalue: AnyCodable?
public var fastmode: Bool? { fastmodevalue?.value as? Bool }
public let fastautoonseconds: Int?
public let deliver: Bool?
public let originatingchannel: String?
public let originatingto: String?
@@ -7290,6 +7292,46 @@ public struct ChatSendParams: Codable, Sendable {
public let suppresscommandinterpretation: Bool?
public let idempotencykey: String
public init(
sessionkey: String,
agentid: String? = nil,
sessionid: String?,
message: String,
thinking: String?,
fastmodevalue: AnyCodable?,
fastautoonseconds: Int?,
deliver: Bool?,
originatingchannel: String?,
originatingto: String?,
originatingaccountid: String?,
originatingthreadid: String?,
attachments: [AnyCodable]?,
timeoutms: Int?,
systeminputprovenance: [String: AnyCodable]?,
systemprovenancereceipt: String?,
suppresscommandinterpretation: Bool?,
idempotencykey: String)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.sessionid = sessionid
self.message = message
self.thinking = thinking
self.fastmodevalue = fastmodevalue
self.fastautoonseconds = fastautoonseconds
self.deliver = deliver
self.originatingchannel = originatingchannel
self.originatingto = originatingto
self.originatingaccountid = originatingaccountid
self.originatingthreadid = originatingthreadid
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt
self.suppresscommandinterpretation = suppresscommandinterpretation
self.idempotencykey = idempotencykey
}
public init(
sessionkey: String,
agentid: String? = nil,
@@ -7309,23 +7351,25 @@ public struct ChatSendParams: Codable, Sendable {
suppresscommandinterpretation: Bool?,
idempotencykey: String)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.sessionid = sessionid
self.message = message
self.thinking = thinking
self.fastmode = fastmode
self.deliver = deliver
self.originatingchannel = originatingchannel
self.originatingto = originatingto
self.originatingaccountid = originatingaccountid
self.originatingthreadid = originatingthreadid
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt
self.suppresscommandinterpretation = suppresscommandinterpretation
self.idempotencykey = idempotencykey
self.init(
sessionkey: sessionkey,
agentid: agentid,
sessionid: sessionid,
message: message,
thinking: thinking,
fastmodevalue: fastmode.map { AnyCodable($0) },
fastautoonseconds: nil,
deliver: deliver,
originatingchannel: originatingchannel,
originatingto: originatingto,
originatingaccountid: originatingaccountid,
originatingthreadid: originatingthreadid,
attachments: attachments,
timeoutms: timeoutms,
systeminputprovenance: systeminputprovenance,
systemprovenancereceipt: systemprovenancereceipt,
suppresscommandinterpretation: suppresscommandinterpretation,
idempotencykey: idempotencykey)
}
private enum CodingKeys: String, CodingKey {
@@ -7334,7 +7378,8 @@ public struct ChatSendParams: Codable, Sendable {
case sessionid = "sessionId"
case message
case thinking
case fastmode = "fastMode"
case fastmodevalue = "fastMode"
case fastautoonseconds = "fastAutoOnSeconds"
case deliver
case originatingchannel = "originatingChannel"
case originatingto = "originatingTo"

View File

@@ -1,4 +1,4 @@
24f11880cec619997ff93d303c32431bf4fb2bfefb56c9f0ece35ff91b329a80 config-baseline.json
3ac3be8b7e201eb577854806a9806ba90acbfb2616e14b3ffd1169f188620303 config-baseline.json
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
769899651e2769833ae7e9c8fbf402e55f3d5e32da6bfe21a9659cc35d1f07bb config-baseline.channel.json
d2e2114f1cd43dc894fe1a4836677b42a2a5af825537d6c4a932da832d58a590 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl
b03809275ae18fd5c843335a82304eb6e52905847406ae24a8b0b591205e9fc5 plugin-sdk-api-baseline.json
1f94e05ce0565d2a4fd8797ebde3366060eac1606fdd88b67a60c37e865e312b plugin-sdk-api-baseline.jsonl

View File

@@ -183,7 +183,7 @@ Model-selection precedence for isolated jobs is:
3. User-selected stored cron session model override
4. Agent/default model selection
Fast mode follows the resolved live selection too. If the selected model config has `params.fastMode`, isolated cron uses that by default. A stored session `fastMode` override still wins over config in either direction.
Fast mode follows the resolved live selection too. If the selected model config has `params.fastMode`, isolated cron uses that by default. A stored session `fastMode` override still wins over config in either direction. Auto mode uses the selected model's `params.fastAutoOnSeconds` cutoff when present, defaulting to 60 seconds.
If an isolated run hits a live model-switch handoff, cron retries with the switched provider/model and persists that live selection for the active run before retrying. When the switch also carries a new auth profile, cron persists that auth profile override for the active run too. Retries are bounded: after the initial attempt plus 2 switch retries, cron aborts instead of looping forever.

View File

@@ -94,28 +94,28 @@ Use this checklist when you already know your old BlueBubbles config and want th
iMessage and BlueBubbles share a lot of channel-level config. The keys that change are mostly transport (REST server vs local CLI). Behavior keys (`dmPolicy`, `groupPolicy`, `allowFrom`, etc.) keep the same meaning.
| BlueBubbles | bundled iMessage | Notes |
| ---------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channels.bluebubbles.enabled` | `channels.imessage.enabled` | Same semantics. |
| `channels.bluebubbles.serverUrl` | _(removed)_ | No REST server — the plugin spawns `imsg rpc` over stdio. |
| `channels.bluebubbles.password` | _(removed)_ | No webhook authentication needed. |
| _(implicit)_ | `channels.imessage.cliPath` | Path to `imsg` (default `imsg`); use a wrapper script for SSH. |
| _(implicit)_ | `channels.imessage.dbPath` | Optional Messages.app `chat.db` override; auto-detected when omitted. |
| _(implicit)_ | `channels.imessage.remoteHost` | `host` or `user@host` — only needed when `cliPath` is an SSH wrapper and you want SCP attachment fetches. |
| `channels.bluebubbles.dmPolicy` | `channels.imessage.dmPolicy` | Same values (`pairing` / `allowlist` / `open` / `disabled`). |
| `channels.bluebubbles.allowFrom` | `channels.imessage.allowFrom` | Pairing approvals carry over by handle, not by token. |
| `channels.bluebubbles.groupPolicy` | `channels.imessage.groupPolicy` | Same values (`allowlist` / `open` / `disabled`). |
| `channels.bluebubbles.groupAllowFrom` | `channels.imessage.groupAllowFrom` | Same. |
| `channels.bluebubbles.groups` | `channels.imessage.groups` | **Copy this verbatim, including any `groups: { "*": { ... } }` wildcard entry.** Per-group `requireMention`, `tools`, `toolsBySender` carry over. With `groupPolicy: "allowlist"`, an empty or missing `groups` block silently drops every group message — see "Group registry footgun" below. |
| `channels.bluebubbles.sendReadReceipts` | `channels.imessage.sendReadReceipts` | Default `true`. With the bundled plugin this only fires when the private API probe is up. |
| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same shape, **same off-by-default**. If you had attachments flowing on BlueBubbles you must re-set this explicitly on the iMessage block — it does not carry over implicitly, and inbound photos/media will be silently dropped with no `Inbound message` log line until you do. |
| `channels.bluebubbles.attachmentRoots` | `channels.imessage.attachmentRoots` | Local roots; same wildcard rules. |
| _(N/A)_ | `channels.imessage.remoteAttachmentRoots` | Only used when `remoteHost` is set for SCP fetches. |
| `channels.bluebubbles.mediaMaxMb` | `channels.imessage.mediaMaxMb` | Default 16 MB on iMessage (BlueBubbles default was 8 MB). Set explicitly if you want to keep the lower cap. |
| `channels.bluebubbles.textChunkLimit` | `channels.imessage.textChunkLimit` | Default 4000 on both. |
| `channels.bluebubbles.coalesceSameSenderDms` | `channels.imessage.coalesceSameSenderDms` | Same opt-in. DM-only — group chats keep instant per-message dispatch on both channels. Widens the default inbound debounce to 2500 ms when enabled without an explicit `messages.inbound.byChannel.imessage`. See [iMessage docs § Coalescing split-send DMs](/channels/imessage#coalescing-split-send-dms-command--url-in-one-composition). |
| `channels.bluebubbles.enrichGroupParticipantsFromContacts` | _(N/A)_ | iMessage already reads sender display names from `chat.db`. |
| `channels.bluebubbles.actions.*` | `channels.imessage.actions.*` | Per-action toggles: `reactions`, `edit`, `unsend`, `reply`, `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, `sendAttachment`. |
| BlueBubbles | bundled iMessage | Notes |
| ---------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `channels.bluebubbles.enabled` | `channels.imessage.enabled` | Same semantics. |
| `channels.bluebubbles.serverUrl` | _(removed)_ | No REST server — the plugin spawns `imsg rpc` over stdio. |
| `channels.bluebubbles.password` | _(removed)_ | No webhook authentication needed. |
| _(implicit)_ | `channels.imessage.cliPath` | Path to `imsg` (default `imsg`); use a wrapper script for SSH. |
| _(implicit)_ | `channels.imessage.dbPath` | Optional Messages.app `chat.db` override; auto-detected when omitted. |
| _(implicit)_ | `channels.imessage.remoteHost` | `host` or `user@host` — only needed when `cliPath` is an SSH wrapper and you want SCP attachment fetches. |
| `channels.bluebubbles.dmPolicy` | `channels.imessage.dmPolicy` | Same values (`pairing` / `allowlist` / `open` / `disabled`). |
| `channels.bluebubbles.allowFrom` | `channels.imessage.allowFrom` | Pairing approvals carry over by handle, not by token. |
| `channels.bluebubbles.groupPolicy` | `channels.imessage.groupPolicy` | Same values (`allowlist` / `open` / `disabled`). |
| `channels.bluebubbles.groupAllowFrom` | `channels.imessage.groupAllowFrom` | Same. |
| `channels.bluebubbles.groups` | `channels.imessage.groups` | **Copy this verbatim, including any `groups: { "*": { ... } }` wildcard entry.** Per-group `requireMention`, `tools`, `toolsBySender` carry over. With `groupPolicy: "allowlist"`, an empty or missing `groups` block silently drops every group message — see "Group registry footgun" below. |
| `channels.bluebubbles.sendReadReceipts` | `channels.imessage.sendReadReceipts` | Default `true`. With the bundled plugin this only fires when the private API probe is up. |
| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same shape, **same off-by-default**. If you had attachments flowing on BlueBubbles you must re-set this explicitly on the iMessage block — it does not carry over implicitly, and inbound photos/media will be silently dropped with no `Inbound message` log line until you do. |
| `channels.bluebubbles.attachmentRoots` | `channels.imessage.attachmentRoots` | Local roots; same wildcard rules. |
| _(N/A)_ | `channels.imessage.remoteAttachmentRoots` | Only used when `remoteHost` is set for SCP fetches. |
| `channels.bluebubbles.mediaMaxMb` | `channels.imessage.mediaMaxMb` | Default 16 MB on iMessage (BlueBubbles default was 8 MB). Set explicitly if you want to keep the lower cap. |
| `channels.bluebubbles.textChunkLimit` | `channels.imessage.textChunkLimit` | Default 4000 on both. |
| `channels.bluebubbles.coalesceSameSenderDms` | `channels.imessage.coalesceSameSenderDms` | Same opt-in. DM-only — group chats keep instant per-message dispatch on both channels. Widens the default inbound debounce to 7000 ms when enabled without an explicit `messages.inbound.byChannel.imessage` or global `messages.inbound.debounceMs`. See [iMessage docs § Coalescing split-send DMs](/channels/imessage#coalescing-split-send-dms-command--url-in-one-composition). |
| `channels.bluebubbles.enrichGroupParticipantsFromContacts` | _(N/A)_ | iMessage already reads sender display names from `chat.db`. |
| `channels.bluebubbles.actions.*` | `channels.imessage.actions.*` | Per-action toggles: `reactions`, `edit`, `unsend`, `reply`, `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, `sendAttachment`. |
Multi-account configs (`channels.bluebubbles.accounts.*`) translate one-to-one to `channels.imessage.accounts.*`.

View File

@@ -681,7 +681,7 @@ The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalesc
}
```
With the flag on and no explicit `messages.inbound.byChannel.imessage`, the debounce window widens to **2500 ms** (the legacy default is 0 ms — no debouncing). The wider window is required because Apple's split-send cadence of 0.8-2.0 s does not fit in a tighter default.
With the flag on and no explicit `messages.inbound.byChannel.imessage` or global `messages.inbound.debounceMs`, the debounce window widens to **7000 ms** (the legacy default is 0 ms — no debouncing). The wider window is required because Apple's URL-preview split-send cadence can stretch to several seconds while Messages.app emits the preview row.
To tune the window yourself:
@@ -690,10 +690,8 @@ The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalesc
messages: {
inbound: {
byChannel: {
// 2500 ms works for most setups; raise to 4000 ms if your Mac is
// slow or under memory pressure (observed gap can stretch past 2 s
// then).
imessage: 2500,
// 7000 ms covers observed Messages.app URL-preview delays.
imessage: 7000,
},
},
},
@@ -715,15 +713,15 @@ The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalesc
The "Flag on" column shows behavior on an `imsg` build that emits `balloon_bundle_id`. On older `imsg` builds that emit no balloon metadata at all, the rows below marked "Two turns" / "N turns" instead fall back to a legacy merge (one turn): OpenClaw cannot structurally tell a split-send from separate sends, so it preserves the pre-metadata merge. Precise separation activates once the build emits balloon metadata.
| User composes | `chat.db` produces | Flag off (default) | Flag on + window (imsg emits balloon metadata) |
| ------------------------------------------------------------------ | ----------------------------------- | --------------------------------------- | ------------------------------------------------ |
| `Dump https://example.com` (one send) | 2 rows ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` |
| `Save this 📎image.jpg caption` (attachment + text) | 2 rows without URL balloon metadata | Two turns | Two turns (legacy merge on metadata-less builds) |
| `/status` (standalone command) | 1 row | Instant dispatch | **Wait up to window, then dispatch** |
| URL pasted alone | 1 row | Instant dispatch | Wait up to window, then dispatch |
| Text + URL sent as two deliberate separate messages, minutes apart | 2 rows outside window | Two turns | Two turns (window expires between them) |
| Rapid flood (>10 small DMs inside window) | N rows without URL balloon metadata | N turns | N turns (legacy merge on metadata-less builds) |
| Two people typing in a group chat | N rows from M senders | M+ turns (one per sender bucket) | M+ turns — group chats are not coalesced |
| User composes | `chat.db` produces | Flag off (default) | Flag on + window (imsg emits balloon metadata) |
| ------------------------------------------------------------------ | ----------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `Dump https://example.com` (one send) | 2 rows ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` |
| `Save this 📎image.jpg caption` (attachment + text) | 2 rows without URL balloon metadata | Two turns | Two turns after metadata is observed; one merged turn on old/pre-latch metadata-less sessions |
| `/status` (standalone command) | 1 row | Instant dispatch | **Wait up to window, then dispatch** |
| URL pasted alone | 1 row | Instant dispatch | Wait up to window, then dispatch |
| Text + URL sent as two deliberate separate messages, minutes apart | 2 rows outside window | Two turns | Two turns (window expires between them) |
| Rapid flood (>10 small DMs inside window) | N rows without URL balloon metadata | N turns | N turns after metadata is observed; one bounded merged turn on old/pre-latch metadata-less sessions |
| Two people typing in a group chat | N rows from M senders | M+ turns (one per sender bucket) | M+ turns — group chats are not coalesced |
## Inbound recovery after a bridge or gateway restart

View File

@@ -39,6 +39,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Nextcloud Talk](/channels/nextcloud-talk) - Self-hosted chat via Nextcloud Talk (bundled plugin).
- [Nostr](/channels/nostr) - Decentralized DMs via NIP-04 (bundled plugin).
- [QQ Bot](/channels/qqbot) - QQ Bot API; private chat, group chat, and rich media (bundled plugin).
- [Raft](/channels/raft) - Raft CLI wake bridge for human and agent collaboration (external plugin).
- [Signal](/channels/signal) - signal-cli; privacy-focused.
- [Slack](/channels/slack) - Bolt SDK; workspace apps.
- [SMS](/channels/sms) - Twilio-backed SMS through the Gateway webhook (bundled plugin).

147
docs/channels/raft.md Normal file
View File

@@ -0,0 +1,147 @@
---
summary: "Raft External Agent support through the Raft CLI wake bridge"
read_when:
- You want to connect OpenClaw to a Raft workspace
- You are configuring a Raft External Agent
- You are debugging Raft wake delivery
title: "Raft"
sidebarTitle: "Raft"
---
Raft support connects an OpenClaw agent to a Raft External Agent through the local
Raft CLI. Raft sends authenticated wake hints to the Gateway. The agent then uses
the Raft CLI to check and send messages.
## Install
Raft is an official external plugin. Install it on the Gateway host:
```bash
openclaw plugins install @openclaw/raft
openclaw gateway restart
```
Details: [Plugins](/tools/plugin)
## Prerequisites
- A Raft workspace with an External Agent.
- The Raft CLI installed on the same host as the OpenClaw Gateway.
- A Raft CLI profile that is already signed in and associated with that External Agent.
The plugin does not store Raft credentials. The Raft CLI keeps that authentication
in its own profile.
## Configure
Set the profile in config:
```json5
{
channels: {
raft: {
enabled: true,
profile: "openclaw",
},
},
}
```
For the default account, you can instead set `RAFT_PROFILE` in the Gateway
environment:
```bash
RAFT_PROFILE=openclaw
```
Use a named account when one Gateway connects to more than one Raft External Agent:
```json5
{
channels: {
raft: {
accounts: {
support: {
profile: "support-agent",
},
engineering: {
profile: "engineering-agent",
},
},
},
},
}
```
The interactive setup flow records the same profile:
```bash
openclaw channels setup raft
```
## How It Works
When the Gateway starts, the plugin:
1. Opens a loopback-only HTTP wake endpoint on an ephemeral port.
2. Starts `raft --profile <profile> agent bridge` with that endpoint and a
per-process token.
3. Accepts only authenticated, content-free wake hints with a replay identity from the local bridge.
4. Requires one of `eventId`, `attemptId`, `messageId`, `delivery_id`, `wake_id`, or `id`.
5. Deduplicates recent retried wake deliveries by bridge event id, including across Gateway restarts.
6. Returns a stable runtime session for the current bridge and an empty activity-drain batch for the Raft CLI protocol.
7. Starts one serialized OpenClaw agent turn for each accepted wake.
The bridge owns Raft delivery retries and reconnects. The OpenClaw turn receives
only a wake notice, not a copied Raft message body. It uses the CLI to read
pending messages and to send its response:
```bash
raft --profile openclaw message check
raft --profile openclaw message send
```
<Note>
Raft is not a normal push-message transport. OpenClaw does not automatically
send the model's final text back through the bridge, so the agent must use the
Raft CLI after processing a wake.
</Note>
## Verify
Check that OpenClaw can find the CLI and has a configured profile:
```bash
openclaw channels status --probe
openclaw plugins inspect raft --runtime --json
```
Then send a message to the Raft External Agent. The Gateway log should show the
Raft bridge starting, followed by an inbound wake. The agent should use the
configured Raft profile to check its pending messages.
## Troubleshooting
<AccordionGroup>
<Accordion title="Raft CLI is missing">
Install the Raft CLI on the Gateway host and make `raft` available on the
service's `PATH`. Verify it with `raft --help`, then restart the Gateway.
</Accordion>
<Accordion title="The bridge exits immediately">
Verify the configured profile is signed in and belongs to the intended
Raft External Agent. Run `raft --profile <profile> agent bridge` directly
to see the CLI diagnostic.
</Accordion>
<Accordion title="A wake arrives but no Raft response is sent">
This is expected when the agent does not invoke the Raft CLI. The wake
bridge does not carry message bodies or automatic final replies. Check the
agent's tool policy and ensure it can run `raft --profile <profile> message
check` and `message send`.
</Accordion>
</AccordionGroup>
## References
- [Raft](https://raft.build/)
- [Raft documentation](https://docs.raft.build/welcome/)
- [Hermes Raft integration](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/raft)

View File

@@ -74,7 +74,7 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
- **Windows Node checks** are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes.
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: plugin contracts and channel contracts each run as two weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, shared, and three cron domain shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional-*` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped into one prompt-heavy shard and one combined shard for the remaining guard stripes, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: plugin contracts and channel contracts each run as two weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, shared, and three cron domain shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Normal CI then packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites; heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional-*` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped into one prompt-heavy shard and one combined shard for the remaining guard stripes, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest` and then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles the flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push.
@@ -111,15 +111,15 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
## Runners
| Runner | Jobs |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ubuntu-24.04` | Manual CI dispatch and non-canonical repository fallbacks, workflow-sanity, labeler, auto-response, docs workflows outside CI, and install-smoke preflight so the Blacksmith matrix can queue earlier |
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, `preflight`, `security-fast`, lower-weight extension shards, `checks-fast-core`, plugin/channel contract shards, `checks-node-compat-node22`, `check-guards`, `check-prod-types`, and `check-test-types` |
| `blacksmith-8vcpu-ubuntu-2404` | Linux Node test shards, bundled plugin test shards, `check-additional-*` shards, `check-dependencies`, and `android` |
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
| Runner | Jobs |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ubuntu-24.04` | Manual CI dispatch and non-canonical repository fallbacks, workflow-sanity, labeler, auto-response, docs workflows outside CI, and install-smoke preflight so the Blacksmith matrix can queue earlier |
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, `preflight`, `security-fast`, lower-weight extension shards, `checks-fast-core`, plugin/channel contract shards, most bundled/lower-weight Linux Node shards, `check-guards`, `check-prod-types`, `check-test-types`, selected `check-additional-*` shards, and `check-dependencies` |
| `blacksmith-8vcpu-ubuntu-2404` | Retained heavy Linux Node suites, boundary/extension-heavy `check-additional-*` shards, and `android` |
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.

View File

@@ -197,7 +197,7 @@ Isolated cron resolves the active model in this order:
### Fast mode
Isolated cron fast mode follows the resolved live model selection. Model config `params.fastMode` applies by default, but a stored session `fastMode` override still wins over config.
Isolated cron fast mode follows the resolved live model selection. Model config `params.fastMode` applies by default, but a stored session `fastMode` override still wins over config. When the resolved mode is `auto`, the cutoff uses the selected model's `params.fastAutoOnSeconds` value, defaulting to 60 seconds.
### Live model switch retries

View File

@@ -165,10 +165,15 @@ When you set `--url`, the CLI does not fall back to config or environment creden
```bash
openclaw gateway health --url ws://127.0.0.1:18789
openclaw gateway health --port 18789
```
The HTTP `/healthz` endpoint is a liveness probe: it returns once the server can answer HTTP. The HTTP `/readyz` endpoint is stricter and stays red while startup plugin sidecars, channels, or configured hooks are still settling. Local or authenticated detailed readiness responses include an `eventLoop` diagnostic block with event-loop delay, event-loop utilization, CPU core ratio, and a `degraded` flag.
<ParamField path="--port <port>" type="number">
Target a local loopback Gateway on this port. This overrides `OPENCLAW_GATEWAY_URL` and `OPENCLAW_GATEWAY_PORT` for the health call.
</ParamField>
### `gateway usage-cost`
Fetch usage-cost summaries from session logs.
@@ -340,8 +345,13 @@ If multiple probe targets are reachable, it prints all of them. An SSH tunnel, T
```bash
openclaw gateway probe
openclaw gateway probe --json
openclaw gateway probe --port 18789
```
<ParamField path="--port <port>" type="number">
Use this port for the local loopback probe target and SSH tunnel remote port. Without `--url`, this selects the local loopback target instead of configured gateway environment URL, environment port, or remote targets.
</ParamField>
<AccordionGroup>
<Accordion title="Interpretation">
- `Reachable: yes` means at least one target accepted a WebSocket connect.

View File

@@ -1122,6 +1122,7 @@
"channels/mattermost",
"channels/nextcloud-talk",
"channels/nostr",
"channels/raft",
"channels/tlon",
"channels/synology-chat",
"channels/twitch"

View File

@@ -1099,7 +1099,7 @@ for provider examples and precedence.
- `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills.
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive | max`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set. The selected provider/model profile controls which values are valid; for Google Gemini, `adaptive` keeps provider-owned dynamic thinking (`thinkingLevel` omitted on Gemini 3/3.1, `thinkingBudget: -1` on Gemini 2.5).
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Overrides `agents.defaults.reasoningDefault` for this agent when no per-message or session reasoning override is set.
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
- `fastModeDefault`: optional per-agent default for fast mode (`"auto" | true | false`). Applies when no per-message or session fast-mode override is set.
- `models`: optional per-agent model catalog/runtime overrides keyed by full `provider/model` ids. Use `models["provider/model"].agentRuntime` for per-agent runtime exceptions.
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.

View File

@@ -160,6 +160,7 @@ must be paired with `--lint`; regular doctor and repair runs reject them.
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
</Accordion>
<Accordion title="Gateway, services, and supervisors">
- Sandbox image repair when sandboxing is enabled.

View File

@@ -445,6 +445,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `sessions.get` returns the full stored session row.
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY` / `no_reply` are omitted, and oversized rows can be replaced with placeholders.
- `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Clients pass `sessionKey`, optional `agentId` when the session selection is agent-scoped, plus a transcript `messageId` previously surfaced through `chat.history`, and the Gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized.
- `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds and can be configured per model with `agents.defaults.models["<provider>/<model>"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request.
</Accordion>

View File

@@ -174,6 +174,7 @@ troubleshooting, see the main [FAQ](/help/faq).
- **Per session:** send `/fast on` while the session is using `openai/gpt-5.5`.
- **Per model default:** set `agents.defaults.models["openai/gpt-5.5"].params.fastMode` to `true`.
- **Automatic cutoff:** use `/fast auto` or `params.fastMode: "auto"` to start new model calls fast until the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds; set `params.fastAutoOnSeconds` on the active model to change it.
Example:
@@ -184,7 +185,8 @@ troubleshooting, see the main [FAQ](/help/faq).
models: {
"openai/gpt-5.5": {
params: {
fastMode: true,
fastMode: "auto",
fastAutoOnSeconds: 30,
},
},
},
@@ -193,7 +195,7 @@ troubleshooting, see the main [FAQ](/help/faq).
}
```
For OpenAI, fast mode maps to `service_tier = "priority"` on supported native Responses requests. Session `/fast` overrides beat config defaults.
For OpenAI, fast mode maps to `service_tier = "priority"` on supported native Responses requests. Session `/fast` overrides beat config defaults. Codex app-server turns can only receive the tier at turn start, so `auto` applies on the next OpenClaw-started model turn rather than inside one already-running app-server turn.
See [Thinking and fast mode](/tools/thinking) and [OpenAI fast mode](/providers/openai#fast-mode).

View File

@@ -145,11 +145,6 @@ local proof.
Use `definePluginEntry` for non-channel plugins. Channel plugins use
`defineChannelPluginEntry`.
Tool handlers may accept an optional fifth execution-context argument when
they need runtime-owned facts for the current call. The context includes the
active `runId`, effective `sessionKey`, ephemeral `sessionId`, owning
`agentId`, and ambient `deliveryContext` when those values are available.
</Step>
<Step title="Test the runtime">

View File

@@ -146,6 +146,7 @@ observation-only.
- `subagent_delivery_target` - compatibility hook for completion delivery when no core session binding can project a route.
- `subagent_spawning` - deprecated compatibility hook. Core now prepares `thread: true` subagent bindings through channel session-binding adapters before `subagent_spawned` fires.
- `subagent_spawned` includes `resolvedModel` and `resolvedProvider` when OpenClaw has resolved the child session's native model before launch.
- `subagent_ended` carries `targetSessionKey` (identity — this matches `subagent_spawned.childSessionKey`), `targetKind` (`"subagent"` or `"acp"`), `reason`, optional `outcome` (`"ok"`, `"error"`, `"timeout"`, `"killed"`, `"reset"`, or `"deleted"`), optional `error`, `runId`, `endedAt`, `accountId`, and `sendFarewell`. It does **not** include `agentId` or `childSessionKey`; use `targetSessionKey` to correlate with the corresponding `subagent_spawned` event.
**Lifecycle**
@@ -318,10 +319,56 @@ Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
plugin hooks can scope metrics, side effects, or state to a specific scheduled
job.
For channel-originated runs, `ctx.messageProvider` is the provider surface such
as `discord` or `telegram`, while `ctx.channelId` is the conversation target
identifier when OpenClaw can derive one from the session key or delivery
metadata.
For channel-originated runs, `ctx.channel` and `ctx.messageProvider` identify
the provider surface such as `discord` or `telegram`, while `ctx.channelId` is
the conversation target identifier when OpenClaw can derive one from the session
key or delivery metadata.
When sender identity is available, agent hook contexts also include:
- `ctx.senderId` — channel-scoped sender ID (e.g. Feishu `open_id`, Discord
user ID). Populated when the run originates from a user message with known
sender metadata.
- `ctx.chatId` — transport-native conversation identifier (e.g. Feishu
`chat_id`, Telegram `chat_id`). Populated when the originating channel
provides a native conversation ID.
- `ctx.channelContext.sender.id` — the same sender ID as `ctx.senderId`, under a
channel-owned object that plugins can extend with channel-specific fields.
- `ctx.channelContext.chat.id` — the same conversation ID as `ctx.chatId`, under a
channel-owned object that plugins can extend with channel-specific fields.
Core only defines the nested `id` fields. Channel plugins that pass richer
sender or chat metadata through the inbound helper can augment
`PluginHookChannelSenderContext` or `PluginHookChannelChatContext` from
`openclaw/plugin-sdk/channel-inbound`:
```ts
declare module "openclaw/plugin-sdk/channel-inbound" {
interface PluginHookChannelSenderContext {
unionId?: string;
userId?: string;
}
}
```
Channel plugins pass those fields through the inbound SDK helper:
```ts
buildChannelInboundEventContext({
// ...
channelContext: {
sender: { id: senderOpenId, unionId, userId },
chat: { id: chatId },
},
});
```
These fields are optional and absent for system-originated runs (heartbeat,
cron, exec-event).
`ctx.senderExternalId` remains as a deprecated source-compatibility field for
older plugins. Core does not populate it; new channel-specific sender identities
should live under `ctx.channelContext.sender` through module augmentation.
`agent_end` is an observation hook. Gateway and persistent harness paths run it
fire-and-forget after the turn, while short-lived one-shot CLI paths wait for the

View File

@@ -199,7 +199,7 @@ Each entry lists the package, distribution route, and description.
## Official external packages
54 plugins
55 plugins
- **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management.
@@ -289,6 +289,8 @@ Each entry lists the package, distribution route, and description.
- **[qwen](/plugins/reference/qwen)** (`@openclaw/qwen-provider`) - npm; ClawHub: `clawhub:@openclaw/qwen-provider`. Adds Qwen, Qwen Cloud, Model Studio, DashScope, Qwen Oauth, Qwen Portal, Qwen CLI model provider support to OpenClaw.
- **[raft](/plugins/reference/raft)** (`@openclaw/raft`) - npm; ClawHub. OpenClaw Raft channel plugin for secure CLI wake bridges.
- **[slack](/plugins/reference/slack)** (`@openclaw/slack`) - npm; ClawHub. OpenClaw Slack channel plugin for channels, DMs, commands, and app events.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm; ClawHub: `clawhub:@openclaw/stepfun-provider`. Adds StepFun, StepFun Plan model provider support to OpenClaw.

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 128
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 129
generated plugin reference pages by distribution, package, and description.

View File

@@ -16,4 +16,4 @@ Experimental Canvas control and A2UI rendering surfaces for paired nodes.
## Surface
contracts: tools
contracts: tools; skills

View File

@@ -16,7 +16,7 @@ OpenClaw Discord channel plugin for channels, DMs, commands, and app events.
## Surface
channels: discord; contracts: transcriptSourceProviders
channels: discord; contracts: transcriptSourceProviders; skills
## Related docs

View File

@@ -0,0 +1,23 @@
---
summary: "OpenClaw Raft channel plugin for secure CLI wake bridges."
read_when:
- You are installing, configuring, or auditing the raft plugin
title: "Raft plugin"
---
# Raft plugin
OpenClaw Raft channel plugin for secure CLI wake bridges.
## Distribution
- Package: `@openclaw/raft`
- Install route: npm; ClawHub
## Surface
channels: raft
## Related docs
- [raft](/channels/raft)

View File

@@ -16,7 +16,7 @@ OpenClaw Slack channel plugin for channels, DMs, commands, and app events.
## Surface
channels: slack
channels: slack; skills
## Related docs

View File

@@ -16,7 +16,7 @@ OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.
## Surface
contracts: tools
contracts: tools; skills
## Related docs

View File

@@ -16,7 +16,7 @@ OpenClaw WhatsApp channel plugin for WhatsApp Web chats.
## Surface
channels: whatsapp
channels: whatsapp; skills
## Related docs

View File

@@ -29,7 +29,10 @@ import {
```
- `buildChannelInboundEventContext(...)`: project normalized channel facts into
the prompt/session context.
the prompt/session context. Use `channelContext` to pass channel-owned
sender/chat metadata through to plugin hook `ctx.channelContext`; augment
`PluginHookChannelSenderContext` or `PluginHookChannelChatContext` from this
subpath for channel-specific fields.
- `runChannelInboundEvent(...)`: run ingest, classify, preflight, resolve,
record, dispatch, and finalize for one inbound platform event.
- `dispatchChannelInboundReply(...)`: record and dispatch an already assembled

View File

@@ -238,11 +238,9 @@ releases.
`api.runtime.config.writeConfigFile(...)` directly. Prefer config that was
already passed into the active call path. Long-lived handlers that need the
current process snapshot can use `api.runtime.config.current()`. Long-lived
factory-created agent tools should use the tool factory context's
`ctx.getRuntimeConfig()` inside `execute` so a tool created before a config
write still sees the refreshed runtime config. For per-call run, session, or
delivery facts, use the tool execution context rather than closing over the
factory context.
agent tools should use the tool context's `ctx.getRuntimeConfig()` inside
`execute` so a tool created before a config write still sees the refreshed
runtime config.
Config writes must go through the transactional helpers and choose an
after-write policy:

View File

@@ -151,14 +151,6 @@ Factories are still for fixed tool names. Use `definePluginEntry` directly when
the plugin computes tool names dynamically or combines tools with hooks,
services, providers, commands, or other runtime surfaces.
Factory context is construction-time state. Use it to decide whether the tool
exists for the run or to bind stable helpers. Per-call state belongs in the
execution context: static tool-plugin `execute` handlers receive it as fields on
their third `context` argument, and factory-created `AgentTool.execute`
handlers receive it as the optional fifth argument. The execution context
includes `runId`, effective `sessionKey`, `sessionId`, `agentId`, and
`deliveryContext` when OpenClaw knows those values.
## Return values
`defineToolPlugin` wraps plain return values into the OpenClaw tool-result

View File

@@ -759,7 +759,7 @@ Tool name: `voice_call`.
| `end_call` | `callId` |
| `get_status` | `callId` |
This repo ships a matching skill doc at `skills/voice-call/SKILL.md`.
The voice-call plugin ships a matching agent skill.
## Gateway RPC

View File

@@ -915,17 +915,17 @@ the Server-side compaction accordion below.
<Accordion title="Fast mode">
OpenClaw exposes a shared fast-mode toggle for `openai/*`:
- **Chat/UI:** `/fast status|on|off`
- **Chat/UI:** `/fast status|auto|on|off`
- **Config:** `agents.defaults.models["<provider>/<model>"].params.fastMode`
When enabled, OpenClaw maps fast mode to OpenAI priority processing (`service_tier = "priority"`). Existing `service_tier` values are preserved, and fast mode does not rewrite `reasoning` or `text.verbosity`.
When enabled, OpenClaw maps fast mode to OpenAI priority processing (`service_tier = "priority"`). Existing `service_tier` values are preserved, and fast mode does not rewrite `reasoning` or `text.verbosity`. `fastMode: "auto"` starts new model calls fast until the auto cutoff, then starts later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds; set `params.fastAutoOnSeconds` on the active model to change it.
```json5
{
agents: {
defaults: {
models: {
"openai/gpt-5.5": { params: { fastMode: true } },
"openai/gpt-5.5": { params: { fastMode: "auto", fastAutoOnSeconds: 30 } },
},
},
},

View File

@@ -92,6 +92,8 @@ Notes:
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
prevent binary hijacking or injected code.
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
- For channel-origin runs, OpenClaw also exposes a narrow sender/chat identity JSON payload in
`OPENCLAW_CHANNEL_CONTEXT` when the channel provided those ids.
- `openclaw channels login` is blocked from `exec` because it is an interactive channel-auth flow; run it in a terminal on the gateway host, or use the channel-native login tool from chat when one exists.
- Important: sandboxing is **off by default**. If sandboxing is off, implicit `host=auto`
resolves to `gateway`. Explicit `host=sandbox` still fails closed instead of silently

View File

@@ -198,7 +198,7 @@ plugins.
| `/think <level\|default>` | Set the thinking level or clear the session override. Aliases: `/thinking`, `/t` |
| `/verbose on\|off\|full` | Toggle verbose output. Alias: `/v` |
| `/trace on\|off` | Toggle plugin trace output for the current session |
| `/fast [status\|on\|off\|default]` | Show, set, or clear fast mode |
| `/fast [status\|auto\|on\|off\|default]` | Show, set, or clear fast mode |
| `/reasoning [on\|off\|stream]` | Toggle reasoning visibility. Alias: `/reason` |
| `/elevated [on\|off\|ask\|full]` | Toggle elevated mode. Alias: `/elev` |
| `/exec host=<auto\|sandbox\|gateway\|node> security=<deny\|allowlist\|full> ask=<off\|on-miss\|always> node=<id>` | Show or set exec defaults |
@@ -211,7 +211,7 @@ plugins.
<Accordion title="verbose / trace / fast / reasoning safety">
- `/verbose` is for debugging — keep it **off** in normal use.
- `/trace` reveals only plugin-owned trace/debug lines; normal verbose chatter stays off.
- `/fast on|off` persists a session override; use the Sessions UI `inherit` option to clear it.
- `/fast auto|on|off` persists a session override; use the Sessions UI `inherit` option to clear it.
- `/fast` is provider-specific: OpenAI/Codex map it to `service_tier=priority`; direct Anthropic requests map it to `service_tier=auto` or `standard_only`.
- `/reasoning`, `/verbose`, and `/trace` are risky in group settings — they may reveal internal reasoning or plugin diagnostics. Keep them off in group chats.

View File

@@ -60,21 +60,22 @@ title: "Thinking levels"
## Fast mode (/fast)
- Levels: `on|off|default`.
- Directive-only message toggles a session fast-mode override and replies `Fast mode enabled.` / `Fast mode disabled.`. Use `/fast default` to clear the session override and inherit the configured default; aliases include `inherit`, `clear`, `reset`, and `unpin`.
- Levels: `auto|on|off|default`.
- Directive-only message toggles a session fast-mode override and replies `Fast mode set to auto.`, `Fast mode enabled.`, or `Fast mode disabled.`. Use `/fast default` to clear the session override and inherit the configured default; aliases include `inherit`, `clear`, `reset`, and `unpin`.
- Send `/fast` (or `/fast status`) with no mode to see the current effective fast-mode state.
- OpenClaw resolves fast mode in this order:
1. Inline/directive-only `/fast on|off` override (`/fast default` clears this layer)
1. Inline/directive-only `/fast auto|on|off` override (`/fast default` clears this layer)
2. Session override
3. Per-agent default (`agents.list[].fastModeDefault`)
4. Per-model config: `agents.defaults.models["<provider>/<model>"].params.fastMode`
5. Fallback: `off`
- `auto` keeps the session/config mode as auto but resolves each new model call independently. Calls that start before the auto cutoff have fast mode enabled; later retry, fallback, tool-result, or continuation calls start with fast mode disabled. The cutoff defaults to 60 seconds; set `agents.defaults.models["<provider>/<model>"].params.fastAutoOnSeconds` on the active model to change it.
- For `openai/*`, fast mode maps to OpenAI priority processing by sending `service_tier=priority` on supported Responses requests.
- For Codex-backed `openai/*` models, fast mode sends the same `service_tier=priority` flag on Codex Responses. OpenClaw keeps one shared `/fast` toggle across both auth paths.
- For Codex-backed `openai/*` / `openai-codex/*` models, fast mode sends the same `service_tier=priority` flag on Codex Responses. Native Codex app-server turns receive the tier only on `turn/start` or thread start/resume, so `auto` cannot retier one already-running app-server turn; it applies to the next model turn OpenClaw starts.
- For direct public `anthropic/*` requests, including OAuth-authenticated traffic sent to `api.anthropic.com`, fast mode maps to Anthropic service tiers: `/fast on` sets `service_tier=auto`, `/fast off` sets `service_tier=standard_only`.
- For `minimax/*` on the Anthropic-compatible path, `/fast on` (or `params.fastMode: true`) rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
- Explicit Anthropic `serviceTier` / `service_tier` model params override the fast-mode default when both are set. OpenClaw still skips Anthropic service-tier injection for non-Anthropic proxy base URLs.
- `/status` shows `Fast` only when fast mode is enabled.
- `/status` shows `Fast` when fast mode is enabled and `Fast:auto` when the configured mode is auto.
## Verbose directives (/verbose or /v)

View File

@@ -49,7 +49,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
"--allowedTools",
"mcp__openclaw__*",
"--disallowedTools",
"ScheduleWakeup,CronCreate",
"ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor",
],
resumeArgs: [
"-p",
@@ -62,7 +62,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
"--allowedTools",
"mcp__openclaw__*",
"--disallowedTools",
"ScheduleWakeup,CronCreate",
"ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor",
"--resume",
"{sessionId}",
],

View File

@@ -10,10 +10,13 @@ import {
resolveClaudeCliExecutionArgs,
} from "./cli-shared.js";
const CLAUDE_CLI_DISALLOWED_TOOLS =
"ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor";
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate");
expect(args?.[disallowedIndex + 1]).toBe(CLAUDE_CLI_DISALLOWED_TOOLS);
}
describe("normalizeClaudePermissionArgs", () => {
@@ -382,4 +385,11 @@ describe("normalizeClaudeBackendConfig", () => {
expect(backend.config.clearEnv).toContain("OTEL_EXPORTER_OTLP_PROTOCOL");
expect(backend.config.clearEnv).toContain("OTEL_SDK_DISABLED");
});
it("disables native background Bash and Monitor tools in args and resumeArgs", () => {
const backend = buildAnthropicCliBackend();
expectDefaultDisallowedTools(backend.config.args);
expectDefaultDisallowedTools(backend.config.resumeArgs);
});
});

View File

@@ -8,6 +8,7 @@ import {
createAnthropicServiceTierWrapper,
createAnthropicThinkingPrefillWrapper,
resolveAnthropicBetas,
resolveAnthropicFastMode,
wrapAnthropicProviderStream,
} from "./stream-wrappers.js";
@@ -172,6 +173,10 @@ describe("anthropic stream wrappers", () => {
expect(captured.headers?.["anthropic-beta"]).toContain(OAUTH_BETA);
expect(captured.headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA);
});
it("ignores unresolved auto fast mode at the provider boundary", () => {
expect(resolveAnthropicFastMode({ fastMode: "auto" })).toBeUndefined();
});
});
describe("createAnthropicThinkingPrefillWrapper", () => {
@@ -282,6 +287,19 @@ describe("Anthropic service_tier payload wrappers", () => {
expect(payload?.service_tier).toBe("standard_only");
});
it("fast mode resolves dynamic service_tier for each stream call", () => {
let enabled = true;
const first = runPayloadWrapper({ apiKey: "sk-ant-api03-test-key" }, (base) =>
createAnthropicFastModeWrapper(base, () => enabled),
);
enabled = false;
const second = runPayloadWrapper({ apiKey: "sk-ant-api03-test-key" }, (base) =>
createAnthropicFastModeWrapper(base, () => enabled),
);
expect(first?.service_tier).toBe("auto");
expect(second?.service_tier).toBe("standard_only");
});
it("explicit service tier injects service_tier=standard_only for regular API keys", () => {
const payload = serviceTierWrapperCases[1].run({
apiKey: "sk-ant-api03-test-key",

View File

@@ -10,7 +10,6 @@ import {
composeProviderStreamWrappers,
createAnthropicThinkingPrefillPayloadWrapper,
resolveAnthropicPayloadPolicy,
stripTrailingAnthropicAssistantPrefillWhenThinking,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
@@ -44,6 +43,7 @@ const OPENCLAW_OAUTH_ANTHROPIC_BETAS = [
] as const;
type AnthropicServiceTier = "auto" | "standard_only";
type DynamicFastMode = boolean | (() => boolean | undefined);
function isAnthropic1MModel(modelId: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(modelId);
@@ -157,9 +157,20 @@ export function createAnthropicBetaHeadersWrapper(
/** Wrap a stream function with the Anthropic fast-mode service tier. */
export function createAnthropicFastModeWrapper(
baseStreamFn: StreamFn | undefined,
enabled: boolean,
enabled: DynamicFastMode,
): StreamFn {
return createAnthropicServiceTierWrapper(baseStreamFn, resolveAnthropicFastServiceTier(enabled));
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const resolved = typeof enabled === "function" ? enabled() : enabled;
if (resolved === undefined) {
return underlying(model, context, options);
}
return createAnthropicServiceTierWrapper(underlying, resolveAnthropicFastServiceTier(resolved))(
model,
context,
options,
);
};
}
/** Wrap a stream function with an explicit Anthropic service tier when allowed. */
@@ -204,9 +215,12 @@ export function createAnthropicThinkingPrefillWrapper(
export function resolveAnthropicFastMode(
extraParams: Record<string, unknown> | undefined,
): boolean | undefined {
return normalizeFastMode(
(extraParams?.fastMode ?? extraParams?.fast_mode) as string | boolean | null | undefined,
);
const raw = extraParams?.fastMode ?? extraParams?.fast_mode;
const fastMode =
typeof raw === "function"
? normalizeFastMode((raw as () => unknown)() as string | boolean | null | undefined)
: normalizeFastMode(raw as string | boolean | null | undefined);
return fastMode === "auto" ? undefined : fastMode;
}
/** Resolve Anthropic service tier from model extra params. */
@@ -232,7 +246,9 @@ export function wrapAnthropicProviderStream(
hasConfiguredAnthropicBeta(ctx.extraParams) ||
(ctx.extraParams?.context1m === true && isAnthropic1MModel(ctx.modelId));
const serviceTier = resolveAnthropicServiceTier(ctx.extraParams);
const fastMode = resolveAnthropicFastMode(ctx.extraParams);
const hasFastModeParam =
ctx.extraParams !== undefined &&
(Object.hasOwn(ctx.extraParams, "fastMode") || Object.hasOwn(ctx.extraParams, "fast_mode"));
return composeProviderStreamWrappers(
ctx.streamFn,
needsAnthropicBetaWrapper
@@ -241,8 +257,9 @@ export function wrapAnthropicProviderStream(
serviceTier
? (streamFn) => createAnthropicServiceTierWrapper(streamFn, serviceTier)
: undefined,
fastMode !== undefined
? (streamFn) => createAnthropicFastModeWrapper(streamFn, fastMode)
hasFastModeParam
? (streamFn) =>
createAnthropicFastModeWrapper(streamFn, () => resolveAnthropicFastMode(ctx.extraParams))
: undefined,
(streamFn) => createAnthropicThinkingPrefillWrapper(streamFn),
);
@@ -251,6 +268,5 @@ export function wrapAnthropicProviderStream(
/** Test-only hooks for Anthropic stream wrapper behavior. */
export const testing = {
log,
stripTrailingAssistantPrefillWhenThinking: stripTrailingAnthropicAssistantPrefillWhenThinking,
};
export { testing as __testing };

View File

@@ -6,6 +6,7 @@
"enabledByDefault": true,
"name": "Canvas",
"description": "Experimental Canvas control and A2UI rendering surfaces for paired nodes.",
"skills": ["./skills"],
"contracts": {
"tools": ["canvas"]
},

View File

@@ -189,7 +189,23 @@ export function isRawToolOutputCompletionNotification(
return false;
}
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
return item ? readString(item, "type") === "custom_tool_call_output" : false;
switch (item ? readString(item, "type") : undefined) {
case "custom_tool_call_output":
case "function_call_output":
return true;
default:
return false;
}
}
export function isRawFunctionToolOutputCompletionNotification(
notification: CodexServerNotification,
): boolean {
if (notification.method !== "rawResponseItem/completed" || !isJsonObject(notification.params)) {
return false;
}
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
return item ? readString(item, "type") === "function_call_output" : false;
}
/** Returns true for progress on Codex-native tool item types. */

View File

@@ -188,7 +188,7 @@ export type CodexAppServerRuntimeOptions = {
approvalPolicySource?: CodexAppServerApprovalPolicySource;
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier;
serviceTier?: CodexServiceTier | null;
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
};

View File

@@ -65,6 +65,7 @@ export type CodexAppServerToolTelemetry = {
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
onNativeToolResultRecorded?: () => void | Promise<void>;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
};
@@ -632,7 +633,7 @@ export class CodexAppServerEventProjector {
}
this.recordToolMeta(item);
this.emitStandardItemEvent({ phase: "start", item });
this.emitNormalizedToolItemEvent({ phase: "start", item });
await this.emitNormalizedToolItemEvent({ phase: "start", item });
this.recordNativeToolTranscriptCall(item);
this.emitToolResultSummary(item);
this.emitAgentEvent({
@@ -696,7 +697,7 @@ export class CodexAppServerEventProjector {
}
this.recordToolMeta(item);
this.emitStandardItemEvent({ phase: "end", item });
this.emitNormalizedToolItemEvent({ phase: "result", item });
await this.emitNormalizedToolItemEvent({ phase: "result", item });
this.recordNativeToolTranscriptCall(item);
this.recordNativeToolTranscriptResult(item);
this.emitToolResultSummary(item);
@@ -816,7 +817,7 @@ export class CodexAppServerEventProjector {
this.emitPlanUpdate({ explanation: undefined, steps: splitPlanText(item.text) });
}
this.recordToolMeta(item);
this.emitSnapshotOnlyNativeToolProgress(item);
await this.emitSnapshotOnlyNativeToolProgress(item);
this.recordNativeToolTranscriptCall(item);
this.recordNativeToolTranscriptResult(item);
this.emitAfterToolCallObservation(item);
@@ -827,7 +828,7 @@ export class CodexAppServerEventProjector {
await this.maybeEndReasoning();
}
private emitSnapshotOnlyNativeToolProgress(item: CodexThreadItem): void {
private async emitSnapshotOnlyNativeToolProgress(item: CodexThreadItem): Promise<void> {
if (
!shouldSynthesizeToolProgressForItem(item) ||
!this.isCurrentTurnSnapshotItem(item) ||
@@ -839,11 +840,11 @@ export class CodexAppServerEventProjector {
const wasStarted = this.activeItemIds.has(item.id);
if (!wasStarted) {
this.emitStandardItemEvent({ phase: "start", item });
this.emitNormalizedToolItemEvent({ phase: "start", item });
await this.emitNormalizedToolItemEvent({ phase: "start", item });
}
this.activeItemIds.delete(item.id);
this.emitStandardItemEvent({ phase: "end", item });
this.emitNormalizedToolItemEvent({ phase: "result", item });
await this.emitNormalizedToolItemEvent({ phase: "result", item });
this.completedItemIds.add(item.id);
}
@@ -1116,10 +1117,10 @@ export class CodexAppServerEventProjector {
});
}
private emitNormalizedToolItemEvent(params: {
private async emitNormalizedToolItemEvent(params: {
phase: "start" | "result";
item: CodexThreadItem | undefined;
}): void {
}): Promise<void> {
const { item } = params;
if (!item || !shouldSynthesizeToolProgressForItem(item)) {
return;
@@ -1139,6 +1140,7 @@ export class CodexAppServerEventProjector {
if (!shouldEmitTranscriptToolProgress(name, args)) {
if (params.phase === "result") {
this.emitAfterToolCallObservation(item);
await this.options.onNativeToolResultRecorded?.();
}
return;
}
@@ -1162,6 +1164,7 @@ export class CodexAppServerEventProjector {
});
if (params.phase === "result") {
this.emitAfterToolCallObservation(item);
await this.options.onNativeToolResultRecorded?.();
}
}

View File

@@ -5617,6 +5617,50 @@ describe("runCodexAppServerAttempt", () => {
expect(resumeRequestParams?.approvalsReviewer).toBe("guardian_subagent");
});
it.each([
{ name: "fast on", fastMode: true, expectedServiceTier: "priority" },
{
name: "fast off",
fastMode: false,
configuredServiceTier: "priority",
expectedServiceTier: null,
},
{
name: "fast auto active",
fastMode: () => true,
expectedServiceTier: "priority",
},
] satisfies Array<{
name: string;
fastMode: EmbeddedRunAttemptParams["fastMode"];
configuredServiceTier?: "priority";
expectedServiceTier?: "priority" | null;
}>)(
"maps $name to app-server resume and turn service tier",
async ({ fastMode, configuredServiceTier, expectedServiceTier }) => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeExistingBinding(sessionFile, workspaceDir, { model: "gpt-5.2" });
const { requests, waitForMethod, completeTurn } = createResumeHarness();
const params = createParams(sessionFile, workspaceDir);
params.fastMode = fastMode;
const options = configuredServiceTier
? { pluginConfig: { appServer: { serviceTier: configuredServiceTier } } }
: {};
const run = runCodexAppServerAttempt(params, options);
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;
for (const method of ["thread/resume", "turn/start"]) {
const request = requests.find((entry) => entry.method === method);
const requestParams = request?.params as Record<string, unknown> | undefined;
expect(requestParams?.serviceTier).toBe(expectedServiceTier);
}
},
);
it("reuses the bound auth profile for app-server startup when params omit it", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -5662,4 +5706,314 @@ describe("runCodexAppServerAttempt", () => {
expect(seenAgentDirs).toEqual([path.join(tempDir, "agent")]);
expect(requests.map((entry) => entry.method)).toContain("turn/start");
});
it("announces Codex app-server fast auto progress after the crossing tool result", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const onToolResult = vi.fn();
const onAgentEvent = vi.fn();
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.verboseLevel = "full";
params.fastModeAuto = true;
params.fastModeStartedAtMs = 1_000;
params.fastModeAutoOnSeconds = 30;
params.onToolResult = onToolResult;
params.onAgentEvent = onAgentEvent;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const notifyCommand = async (id: string, output: string, nowMs: number) => {
await harness.notify({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "commandExecution",
id,
command: `echo ${id}`,
cwd: workspaceDir,
status: "inProgress",
},
},
});
now.mockReturnValue(nowMs);
await harness.notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "commandExecution",
id,
command: `echo ${id}`,
cwd: workspaceDir,
status: "completed",
aggregatedOutput: output,
exitCode: 0,
durationMs: 1,
},
},
});
};
await notifyCommand("tool-before", "before", 20_000);
await notifyCommand("tool-crossing", "crossing", 35_500);
await notifyCommand("tool-after", "after", 42_000);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const payloads = onToolResult.mock.calls.map(([payload]) => payload) as Array<{
channelData?: Record<string, unknown>;
text?: string;
}>;
const texts = payloads.map((payload) => payload.text ?? "");
expect(texts.filter((text) => text.startsWith("💨Fast: auto-off"))).toEqual([
"💨Fast: auto-off(34s>=30s)",
]);
expect(texts.filter((text) => text === "💨Fast: auto-on")).toHaveLength(1);
const offIndex = texts.indexOf("💨Fast: auto-off(34s>=30s)");
const onIndex = texts.indexOf("💨Fast: auto-on");
expect(offIndex).toBeGreaterThan(0);
expect(onIndex).toBeGreaterThan(offIndex + 1);
expect(texts.slice(offIndex + 1, onIndex).some((text) => !text.startsWith("💨Fast:"))).toBe(
true,
);
expect(payloads[offIndex]?.channelData).toEqual({
openclawProgressKind: "fast-mode-auto",
});
expect(payloads[onIndex]?.channelData).toEqual({
openclawProgressKind: "fast-mode-auto",
});
const fastEvents = onAgentEvent.mock.calls
.map(([event]) => event)
.filter((event) => event.stream === "item" && event.data?.title === "Fast");
expect(fastEvents.map((event) => event.data?.summary)).toEqual([
"💨Fast: auto-off(34s>=30s)",
"💨Fast: auto-on",
]);
});
it("does not announce Codex fast auto progress for explicit fast mode", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const onToolResult = vi.fn();
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.fastModeAuto = false;
params.fastModeStartedAtMs = 1_000;
params.fastModeAutoOnSeconds = 30;
params.onToolResult = onToolResult;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
now.mockReturnValue(35_500);
await harness.notify({
method: "rawResponseItem/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "function_call_output",
id: "call-raw-output",
call_id: "call-raw-output",
output: "tool output",
},
},
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const texts = onToolResult.mock.calls.map(([payload]) => payload.text ?? "");
expect(texts.filter((text) => text.startsWith("💨Fast:"))).toEqual([]);
});
it("announces Codex app-server fast auto progress for snapshot-only tool results", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const onToolResult = vi.fn();
const onAgentEvent = vi.fn();
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.verboseLevel = "full";
params.fastModeAuto = true;
params.fastModeStartedAtMs = 1_000;
params.fastModeAutoOnSeconds = 30;
params.onToolResult = onToolResult;
params.onAgentEvent = onAgentEvent;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
now.mockReturnValue(35_500);
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [
{
type: "commandExecution",
id: "tool-crossing",
command: "echo crossing",
commandActions: [],
cwd: workspaceDir,
processId: null,
source: "agent",
status: "completed",
aggregatedOutput: "crossing",
exitCode: 0,
durationMs: 1,
},
],
},
},
});
await run;
const texts = onToolResult.mock.calls.map(([payload]) => payload.text ?? "");
expect(texts.filter((text) => text.startsWith("💨Fast: auto-off"))).toEqual([
"💨Fast: auto-off(34s>=30s)",
]);
expect(texts.filter((text) => text === "💨Fast: auto-on")).toHaveLength(1);
const fastEvents = onAgentEvent.mock.calls
.map(([event]) => event)
.filter((event) => event.stream === "item" && event.data?.title === "Fast");
expect(fastEvents.map((event) => event.data?.summary)).toEqual([
"💨Fast: auto-off(34s>=30s)",
"💨Fast: auto-on",
]);
});
it("announces Codex app-server fast auto progress for raw function call outputs", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const onToolResult = vi.fn();
const onAgentEvent = vi.fn();
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.verboseLevel = "full";
params.fastModeAuto = true;
params.fastModeStartedAtMs = 1_000;
params.fastModeAutoOnSeconds = 30;
params.onToolResult = onToolResult;
params.onAgentEvent = onAgentEvent;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
now.mockReturnValue(35_500);
await harness.notify({
method: "rawResponseItem/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "function_call_output",
id: "call-raw-output",
call_id: "call-raw-output",
output: "tool output",
},
},
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const texts = onToolResult.mock.calls.map(([payload]) => payload.text ?? "");
expect(texts.filter((text) => text.startsWith("💨Fast: auto-off"))).toEqual([
"💨Fast: auto-off(34s>=30s)",
]);
expect(texts.filter((text) => text === "💨Fast: auto-on")).toHaveLength(1);
const fastEvents = onAgentEvent.mock.calls
.map(([event]) => event)
.filter((event) => event.stream === "item" && event.data?.title === "Fast");
expect(fastEvents.map((event) => event.data?.summary)).toEqual([
"💨Fast: auto-off(34s>=30s)",
"💨Fast: auto-on",
]);
});
it("does not duplicate Codex app-server fast auto progress already announced by the outer runner", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const onToolResult = vi.fn();
const onAgentEvent = vi.fn();
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.verboseLevel = "full";
params.fastModeAuto = true;
params.fastModeStartedAtMs = 1_000;
params.fastModeAutoOnSeconds = 30;
params.fastModeAutoProgressState = {
offAnnounced: true,
resetAnnounced: false,
};
params.onToolResult = onToolResult;
params.onAgentEvent = onAgentEvent;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.notify({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "commandExecution",
id: "tool-1",
command: "echo tool-1",
cwd: workspaceDir,
status: "inProgress",
},
},
});
now.mockReturnValue(35_500);
await harness.notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "commandExecution",
id: "tool-1",
command: "echo tool-1",
cwd: workspaceDir,
status: "completed",
aggregatedOutput: "tool output",
exitCode: 0,
durationMs: 1,
},
},
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const texts = onToolResult.mock.calls.map(([payload]) => payload.text ?? "");
expect(texts.filter((text) => text.startsWith("💨Fast: auto-off"))).toEqual([]);
expect(texts.filter((text) => text === "💨Fast: auto-on")).toHaveLength(1);
expect(params.fastModeAutoProgressState).toEqual({
offAnnounced: true,
resetAnnounced: true,
});
const fastEvents = onAgentEvent.mock.calls
.map(([event]) => event)
.filter((event) => event.stream === "item" && event.data?.title === "Fast");
expect(fastEvents.map((event) => event.data?.summary)).toEqual(["💨Fast: auto-on"]);
});
});

View File

@@ -12,6 +12,8 @@ import {
embeddedAgentLog,
emitAgentEvent as emitGlobalAgentEvent,
finalizeHarnessContextEngineTurn,
FAST_MODE_AUTO_PROGRESS_KIND,
formatFastModeAutoProgressText,
formatErrorMessage,
getAgentHarnessHookRunner,
getBeforeToolCallPolicyDiagnosticState,
@@ -27,9 +29,11 @@ import {
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
runHarnessContextEngineMaintenance,
resolveFastModeForElapsed,
setActiveEmbeddedRun,
supportsModelTools,
runAgentCleanupStep,
type FastModeAutoProgressState,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type NativeHookRelayEvent,
@@ -85,6 +89,7 @@ import {
isCurrentThreadOptionalTurnRequestParams,
isCurrentThreadTurnRequestParams,
isNativeResponseStreamDeltaNotification,
isRawFunctionToolOutputCompletionNotification,
isTerminalTurnStatus,
readCodexNotificationItem,
readRawResponseToolCallId,
@@ -274,6 +279,22 @@ const CODEX_APP_SERVER_PROJECTED_CHARS_PER_TOKEN = 4;
const CODEX_APP_SERVER_ACTIVE_NATIVE_TURN_WAIT_TIMEOUT_MS = 30_000;
const ensuredCodexWorkspaceDirs = new Set<string>();
function withCodexAppServerFastModeServiceTier(
appServer: CodexAppServerRuntimeOptions,
params: EmbeddedRunAttemptParams,
): CodexAppServerRuntimeOptions {
const fastMode = typeof params.fastMode === "function" ? params.fastMode() : params.fastMode;
const serviceTier =
fastMode === undefined ? appServer.serviceTier : fastMode ? "priority" : undefined;
if (serviceTier === appServer.serviceTier) {
return appServer;
}
if (serviceTier) {
return { ...appServer, serviceTier };
}
return { ...appServer, serviceTier: null };
}
function estimateCodexAppServerProjectedTurnTokens(params: {
prompt: string;
developerInstructions?: string;
@@ -306,10 +327,10 @@ async function ensureCodexWorkspaceDirOnce(workspaceDir: string): Promise<void>
ensuredCodexWorkspaceDirs.add(normalized);
}
function emitCodexAppServerEvent(
async function emitCodexAppServerEvent(
params: EmbeddedRunAttemptParams,
event: Parameters<NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>>[0],
): void {
): Promise<void> {
try {
emitGlobalAgentEvent({
runId: params.runId,
@@ -321,10 +342,7 @@ function emitCodexAppServerEvent(
embeddedAgentLog.debug("codex app-server global agent event emit failed", { error });
}
try {
const maybePromise = params.onAgentEvent?.(event);
void Promise.resolve(maybePromise).catch((error: unknown) => {
embeddedAgentLog.debug("codex app-server agent event handler rejected", { error });
});
await params.onAgentEvent?.(event);
} catch (error) {
// Event consumers are observational; they must not abort or strand the
// canonical app-server turn lifecycle.
@@ -416,6 +434,14 @@ export async function runCodexAppServerAttempt(
);
const codexModelContentCapture = resolveDiagnosticModelContentCapturePolicy(params.config);
const codexModelCallId = `${params.runId}:codex-model:1`;
const fastModeAutoStartedAtMs =
typeof params.fastModeStartedAtMs === "number" && Number.isFinite(params.fastModeStartedAtMs)
? params.fastModeStartedAtMs
: undefined;
const fastModeAutoProgressState: FastModeAutoProgressState = params.fastModeAutoProgressState ?? {
offAnnounced: false,
resetAnnounced: false,
};
// Startup phase timings are profiler-gated because this function runs before
// every Codex turn; normal production should not do timing bookkeeping here.
const preDynamicStartupStages = createCodexDynamicToolBuildStageTracker({
@@ -764,7 +790,9 @@ export async function runCodexAppServerAttempt(
onYieldDetected: () => {
yieldDetected = true;
},
onCodexAppServerEvent: (event) => emitCodexAppServerEvent(params, event),
onCodexAppServerEvent: (event) => {
void emitCodexAppServerEvent(params, event);
},
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
@@ -791,7 +819,9 @@ export async function runCodexAppServerAttempt(
onYieldDetected: () => {
yieldDetected = true;
},
onCodexAppServerEvent: (event) => emitCodexAppServerEvent(params, event),
onCodexAppServerEvent: (event) => {
void emitCodexAppServerEvent(params, event);
},
});
const toolBridge = createCodexDynamicToolBridge({
tools,
@@ -1424,13 +1454,15 @@ export async function runCodexAppServerAttempt(
};
};
try {
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "startup" },
});
const attemptAppServer = withCodexAppServerFastModeServiceTier(appServer, params);
pluginAppServer = attemptAppServer;
const startupResult = await startCodexAttemptThread({
attemptClientFactory,
appServer,
appServer: attemptAppServer,
pluginConfig,
computerUseConfig,
startupAuthProfileId,
@@ -1469,7 +1501,7 @@ export async function runCodexAppServerAttempt(
codexSandboxPolicy = startupResult.sandboxPolicy;
releaseSharedClientLease = startupResult.releaseSharedClientLease;
restartContextEngineCodexThread = startupResult.restartContextEngineCodexThread;
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready", threadId: thread.threadId },
});
@@ -1713,7 +1745,7 @@ export async function runCodexAppServerAttempt(
};
const emitLifecycleStart = () => {
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "lifecycle",
data: { phase: "start", startedAt: attemptStartedAt },
});
@@ -1724,7 +1756,7 @@ export async function runCodexAppServerAttempt(
if (!lifecycleStarted || lifecycleTerminalEmitted) {
return;
}
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "lifecycle",
data: {
startedAt: attemptStartedAt,
@@ -1760,6 +1792,75 @@ export async function runCodexAppServerAttempt(
emitExecutionPhaseOnce,
});
};
const emitFastModeAutoProgress = async (payload: {
enabled: boolean;
elapsedSeconds: number;
fastAutoOnSeconds?: number;
}): Promise<void> => {
const summary = formatFastModeAutoProgressText(payload);
await emitCodexAppServerEvent(params, {
stream: "item",
data: {
kind: "status",
title: "Fast",
phase: "update",
summary,
},
});
try {
await params.onToolResult?.({
text: summary,
channelData: { openclawProgressKind: FAST_MODE_AUTO_PROGRESS_KIND },
});
} catch (error) {
embeddedAgentLog.debug("codex app-server fast mode auto progress delivery failed", {
error,
});
}
};
const maybeAnnounceFastModeAutoOff = async (): Promise<void> => {
if (
params.fastModeAuto !== true ||
fastModeAutoStartedAtMs === undefined ||
fastModeAutoProgressState.offAnnounced
) {
return;
}
const next = resolveFastModeForElapsed({
mode: "auto",
startedAtMs: fastModeAutoStartedAtMs,
fastAutoOnSeconds: params.fastModeAutoOnSeconds,
});
if (next.enabled) {
return;
}
fastModeAutoProgressState.offAnnounced = true;
await emitFastModeAutoProgress(next);
};
const maybeEmitFastModeAutoReset = async (): Promise<void> => {
if (
params.fastModeAuto !== true ||
!fastModeAutoProgressState.offAnnounced ||
fastModeAutoProgressState.resetAnnounced
) {
return;
}
fastModeAutoProgressState.resetAnnounced = true;
await emitFastModeAutoProgress({
enabled: true,
elapsedSeconds: 0,
fastAutoOnSeconds: params.fastModeAutoOnSeconds,
});
};
const maybeEmitFastModeAutoResetBestEffort = async (): Promise<void> => {
try {
await maybeEmitFastModeAutoReset();
} catch (error) {
embeddedAgentLog.warn(
`codex app-server fast mode auto reset progress failed: ${formatErrorMessage(error)}`,
);
}
};
const isTerminalTurnNotificationForTurn = (
notification: CodexServerNotification,
@@ -1806,6 +1907,13 @@ export async function runCodexAppServerAttempt(
try {
await waitForCodexNotificationDispatchTurn();
await projector.handleNotification(notification);
if (
notificationState.isCurrentTurnNotification &&
activeTurnItemIds.size === 0 &&
isRawFunctionToolOutputCompletionNotification(notification)
) {
await maybeAnnounceFastModeAutoOff();
}
} catch (error) {
embeddedAgentLog.debug("codex app-server projector notification threw", {
method: notification.method,
@@ -2082,7 +2190,7 @@ export async function runCodexAppServerAttempt(
const toolArgs = sanitizeCodexToolArguments(call.arguments);
const shouldEmitDynamicToolProgress = shouldEmitTranscriptToolProgress(call.tool, toolArgs);
if (shouldEmitDynamicToolProgress) {
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "tool",
data: {
phase: "start",
@@ -2172,7 +2280,7 @@ export async function runCodexAppServerAttempt(
});
if (shouldEmitDynamicToolProgress) {
const progressResponse = toCodexDynamicToolProgressResponse(response, protocolResponse);
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "tool",
data: {
phase: "result",
@@ -2322,10 +2430,12 @@ export async function runCodexAppServerAttempt(
throw error;
};
const startCodexTurn = async (): Promise<CodexTurnStartResponse> => {
const turnAppServer = withCodexAppServerFastModeServiceTier(pluginAppServer, params);
pluginAppServer = turnAppServer;
const turnStartParams = buildTurnStartParams(params, {
threadId: thread.threadId,
cwd: codexExecutionCwd,
appServer: pluginAppServer,
appServer: turnAppServer,
promptText: codexTurnPromptText,
sandboxPolicy: codexSandboxPolicy,
environmentSelection: codexEnvironmentSelection,
@@ -2357,7 +2467,7 @@ export async function runCodexAppServerAttempt(
"codex app-server resumed thread has active native turn; waiting before turn/start",
{ threadId: thread.threadId, activeTurnIds: activeNativeTurnIds },
);
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: {
phase: "turn_start_waiting_for_native_turn",
@@ -2380,7 +2490,7 @@ export async function runCodexAppServerAttempt(
ctx: hookContext,
hookRunner,
});
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "turn_starting", threadId: thread.threadId },
});
@@ -2397,7 +2507,7 @@ export async function runCodexAppServerAttempt(
);
const compactTurnCompleted = await waitForActiveNativeTurnCompletion();
if (compactTurnCompleted && !runAbortController.signal.aborted) {
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "turn_start_retry_after_compact", threadId: thread.threadId },
});
@@ -2479,7 +2589,7 @@ export async function runCodexAppServerAttempt(
);
}
}
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready_retry", threadId: thread.threadId },
});
@@ -2509,7 +2619,7 @@ export async function runCodexAppServerAttempt(
error: turnStartErrorMessage,
});
}
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "turn_start_failed", error: turnStartErrorMessage },
});
@@ -2632,6 +2742,7 @@ export async function runCodexAppServerAttempt(
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
nativeHookRelay.shouldRelayEvent("post_tool_use"),
trajectoryRecorder,
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
},
);
if (
@@ -2892,7 +3003,7 @@ export async function runCodexAppServerAttempt(
});
const terminalAssistantText = collectTerminalAssistantText(result);
if (terminalAssistantText && !finalAborted && !finalPromptError) {
emitCodexAppServerEvent(params, {
void emitCodexAppServerEvent(params, {
stream: "assistant",
data: { text: terminalAssistantText },
});
@@ -3024,6 +3135,9 @@ export async function runCodexAppServerAttempt(
systemPromptReport,
};
} finally {
if (params.isFinalFallbackAttempt !== false) {
await maybeEmitFastModeAutoResetBestEffort();
}
codexModelCallDiagnostics.emitError(
"codex app-server run completed without model-call terminal event",
);

View File

@@ -1113,7 +1113,9 @@ export function buildThreadStartParams(
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
...codexThreadSandboxOrPermissions(options.appServer),
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
...(options.appServer.serviceTier !== undefined
? { serviceTier: options.appServer.serviceTier }
: {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
serviceName: "OpenClaw",
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
@@ -1193,7 +1195,9 @@ export function buildThreadResumeParams(
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
...codexThreadSandboxOrPermissions(options.appServer),
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
...(options.appServer.serviceTier !== undefined
? { serviceTier: options.appServer.serviceTier }
: {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
@@ -1428,7 +1432,9 @@ export function buildTurnStartParams(
}),
model: modelSelection.model,
personality: CODEX_NATIVE_PERSONALITY_NONE,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
...(options.appServer.serviceTier !== undefined
? { serviceTier: options.appServer.serviceTier }
: {}),
effort: resolveReasoningEffort(params.thinkLevel, modelSelection.model),
...(options.environmentSelection ? { environments: options.environmentSelection } : {}),
collaborationMode: buildTurnCollaborationMode(params, {

View File

@@ -511,7 +511,7 @@ async function writeThreadBindingFromResponse(
sandbox: resolved.execPolicy?.touched
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox),
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier,
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier ?? undefined,
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
networkProxyConfigFingerprint: resolved.runtime.networkProxy?.configFingerprint,
},
@@ -689,7 +689,7 @@ async function runBoundTurn(params: {
}),
approvalPolicy: typeof approvalPolicy === "string" ? approvalPolicy : undefined,
sandbox,
serviceTier,
serviceTier: serviceTier ?? undefined,
networkProxyProfileName: modelScopedRuntime.networkProxy?.profileName,
networkProxyConfigFingerprint: modelScopedRuntime.networkProxy?.configFingerprint,
},

View File

@@ -192,7 +192,7 @@ export async function setCodexConversationModel(params: {
modelProvider: response.modelProvider ?? modelSelection.modelProvider,
approvalPolicy: binding.approvalPolicy,
sandbox: binding.sandbox,
serviceTier: binding.serviceTier ?? runtime.serviceTier,
serviceTier: binding.serviceTier ?? runtime.serviceTier ?? undefined,
},
lookup,
);

View File

@@ -2,6 +2,7 @@
"id": "discord",
"name": "Discord",
"description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.",
"skills": ["./skills"],
"activation": {
"onStartup": false
},

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { formatVoiceLogPreview } from "./log-preview.js";
describe("formatVoiceLogPreview", () => {
it("collapses whitespace and trims the preview", () => {
expect(formatVoiceLogPreview(" hello \n world\t")).toBe("hello world");
});
it("truncates long previews after 500 characters", () => {
const preview = formatVoiceLogPreview("x".repeat(501));
expect(preview).toBe(`${"x".repeat(500)}...`);
});
});

View File

@@ -0,0 +1,9 @@
const DISCORD_VOICE_LOG_PREVIEW_CHARS = 500;
export function formatVoiceLogPreview(text: string): string {
const oneLine = text.replace(/\s+/g, " ").trim();
if (oneLine.length <= DISCORD_VOICE_LOG_PREVIEW_CHARS) {
return oneLine;
}
return `${oneLine.slice(0, DISCORD_VOICE_LOG_PREVIEW_CHARS)}...`;
}

View File

@@ -35,6 +35,7 @@ import {
resolveDiscordVoiceIngressContext,
runDiscordVoiceAgentTurn,
} from "./ingress.js";
import { formatVoiceLogPreview } from "./log-preview.js";
import {
DiscordRealtimeVoiceSession,
type DiscordVoiceMode,
@@ -67,7 +68,6 @@ import {
import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
const logger = createSubsystemLogger("discord/voice");
const VOICE_LOG_PREVIEW_CHARS = 500;
const FOLLOW_USERS_RECONCILE_INTERVAL_MS = 10_000;
const FOLLOW_USERS_RECONCILE_MAX_GUILDS_PER_RUN = 4;
const FOLLOW_USERS_RECONCILE_MAX_REST_LOOKUPS_PER_RUN = 32;
@@ -96,14 +96,6 @@ type VoiceChannelResidency = {
channelId: string;
};
function formatVoiceLogPreview(text: string): string {
const oneLine = text.replace(/\s+/g, " ").trim();
if (oneLine.length <= VOICE_LOG_PREVIEW_CHARS) {
return oneLine;
}
return `${oneLine.slice(0, VOICE_LOG_PREVIEW_CHARS)}...`;
}
function isVoiceConnectionDestroyed(
connection: DiscordVoiceConnection,
voiceSdk: DiscordVoiceSdk,

View File

@@ -51,6 +51,7 @@ import {
convertDiscordPcm48kStereoToRealtimePcm24kMono,
convertRealtimePcm24kMonoToDiscordPcm48kStereo,
} from "./audio.js";
import { formatVoiceLogPreview } from "./log-preview.js";
import { formatVoiceIngressPrompt } from "./prompt.js";
import { loadDiscordVoiceSdk } from "./sdk-runtime.js";
import {
@@ -81,7 +82,6 @@ const DISCORD_REALTIME_RECENT_AGENT_PROXY_CONSULT_LIMIT = 16;
const DISCORD_REALTIME_RECENT_AGENT_PROXY_CONSULT_TTL_MS = 15_000;
const DISCORD_REALTIME_IGNORED_WAKE_NAME_CONTEXT_TTL_MS = 10_000;
const DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS = 10_000;
const DISCORD_REALTIME_LOG_PREVIEW_CHARS = 500;
const DISCORD_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS = 250;
const DISCORD_REALTIME_FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
const DISCORD_REALTIME_DUPLICATE_ERROR_SUPPRESS_MS = 60_000;
@@ -139,14 +139,6 @@ type AgentProxyConsultState = {
type AgentProxyConsultHandle = RealtimeVoiceForcedConsultHandle<AgentProxyConsultState>;
function formatRealtimeLogPreview(text: string): string {
const oneLine = text.replace(/\s+/g, " ").trim();
if (oneLine.length <= DISCORD_REALTIME_LOG_PREVIEW_CHARS) {
return oneLine;
}
return `${oneLine.slice(0, DISCORD_REALTIME_LOG_PREVIEW_CHARS)}...`;
}
function formatRealtimeInterruptionLog(event: RealtimeVoiceBridgeEvent): string | undefined {
const detail = event.detail ? ` ${event.detail}` : "";
if (event.direction === "client") {
@@ -522,7 +514,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
onTranscript: (role, text, isFinal) => {
if (isFinal && text.trim()) {
logger.info(
`discord voice: realtime ${role} transcript (${text.length} chars): ${formatRealtimeLogPreview(text)}`,
`discord voice: realtime ${role} transcript (${text.length} chars): ${formatVoiceLogPreview(text)}`,
);
}
if (isFinal && role === "assistant") {
@@ -1136,7 +1128,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
return;
}
logger.info(
`discord voice: realtime consult requested call=${callId || "unknown"} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} question=${formatRealtimeLogPreview(consultMessage)}`,
`discord voice: realtime consult requested call=${callId || "unknown"} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} question=${formatVoiceLogPreview(consultMessage)}`,
);
const nativeConsult = this.forcedConsults.recordNativeConsult(event.args, callId);
const pendingConsult = nativeConsult.kind === "pending" ? nativeConsult.handle : undefined;
@@ -1192,7 +1184,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
void promise
.then((text) => {
logger.info(
`discord voice: realtime consult answer (${text.length} chars) voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} speaker=${context.speakerLabel} owner=${context.senderIsOwner}: ${formatRealtimeLogPreview(text)}`,
`discord voice: realtime consult answer (${text.length} chars) voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} speaker=${context.speakerLabel} owner=${context.senderIsOwner}: ${formatVoiceLogPreview(text)}`,
);
session.submitToolResult(callId, { text });
})
@@ -1420,7 +1412,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
if (skipReason) {
const context = this.consumePendingSpeakerContext();
logger.info(
`discord voice: realtime forced agent consult skipped reason=${skipReason} chars=${question.length} speaker=${context?.speakerLabel ?? "unknown"} transcript=${formatRealtimeLogPreview(question)}`,
`discord voice: realtime forced agent consult skipped reason=${skipReason} chars=${question.length} speaker=${context?.speakerLabel ?? "unknown"} transcript=${formatVoiceLogPreview(question)}`,
);
return undefined;
}
@@ -1486,7 +1478,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
this.setRecentAgentProxyConsultPromise(pending, promise);
const text = await promise;
logger.info(
`discord voice: realtime forced agent consult answer (${text.length} chars) elapsedMs=${Date.now() - startedAt} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId}: ${formatRealtimeLogPreview(text)}`,
`discord voice: realtime forced agent consult answer (${text.length} chars) elapsedMs=${Date.now() - startedAt} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId}: ${formatVoiceLogPreview(text)}`,
);
if (text.trim()) {
this.enqueueExactSpeechMessage(text);

View File

@@ -8,6 +8,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { maybeControlDiscordVoiceAgentRun } from "./agent-control.js";
import { createDiscordOpusPlaybackStream } from "./audio.js";
import { resolveDiscordVoiceIngressContext, runDiscordVoiceAgentTurn } from "./ingress.js";
import { formatVoiceLogPreview } from "./log-preview.js";
import { formatVoiceIngressPrompt } from "./prompt.js";
import { loadDiscordVoiceSdk } from "./sdk-runtime.js";
import {
@@ -19,17 +20,8 @@ import {
import type { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
import { synthesizeVoiceReplyAudio, transcribeVoiceAudio } from "./tts.js";
const VOICE_TRANSCRIPT_LOG_PREVIEW_CHARS = 500;
const logger = createSubsystemLogger("discord/voice");
function formatVoiceTranscriptLogPreview(text: string): string {
const oneLine = text.replace(/\s+/g, " ").trim();
if (oneLine.length <= VOICE_TRANSCRIPT_LOG_PREVIEW_CHARS) {
return oneLine;
}
return `${oneLine.slice(0, VOICE_TRANSCRIPT_LOG_PREVIEW_CHARS)}...`;
}
export async function processDiscordVoiceSegment(params: {
entry: VoiceSessionEntry;
wavPath: string;
@@ -78,7 +70,7 @@ export async function processDiscordVoiceSegment(params: {
`transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`,
);
logVoiceVerbose(
`transcript from ${ingress.speakerLabel} (${userId}) in guild ${entry.guildId} channel ${entry.channelId}: ${formatVoiceTranscriptLogPreview(transcript)}`,
`transcript from ${ingress.speakerLabel} (${userId}) in guild ${entry.guildId} channel ${entry.channelId}: ${formatVoiceLogPreview(transcript)}`,
);
if (params.transcripts) {
await params.transcripts.onUtterance({

View File

@@ -23,6 +23,7 @@ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import { shouldSuppressGoogleChatManualExecApprovalFollowupPayload } from "./approval-card-actions.js";
import { formatGoogleChatAllowFromEntry } from "./channel-base.js";
import {
@@ -194,7 +195,11 @@ export const googlechatOutboundAdapter = {
chunker: chunkTextForOutbound,
chunkerMode: "markdown" as const,
textChunkLimit: 4000,
sanitizeText: ({ text }: { text: string }) => sanitizeForPlainText(text),
// Google Chat's plain-text pass does not remove assistant scaffolding.
// Run the canonical delivery sanitizer first so internal tool traces are
// dropped before channel formatting.
sanitizeText: ({ text }: { text: string }) =>
sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
normalizePayload: ({ payload }: { payload: ReplyPayload }) =>
shouldSuppressGoogleChatManualExecApprovalFollowupPayload(payload) ? null : payload,
resolveTarget: ({ to }: { to?: string }) => {

View File

@@ -843,3 +843,21 @@ describe("googlechatPlugin security", () => {
);
});
});
describe("googlechatPlugin outbound sanitizeText", () => {
const sanitizeText = googlechatOutboundAdapter.base.sanitizeText;
it("strips internal tool-trace failure banners from outbound text (#90684)", () => {
const text =
"Visible answer.\n⚠ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed";
const out = sanitizeText({ text });
expect(out).toBe("Visible answer.");
expect(out).not.toContain("failed");
expect(out).not.toContain("🛠️");
});
it("preserves ordinary assistant prose untouched", () => {
const text = "El pipeline tiene 3 deals abiertos por USD 12.000.";
expect(sanitizeText({ text })).toBe(text);
});
});

View File

@@ -12,7 +12,10 @@ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { appendIMessageCliStderrTail, appendIMessageCliStdout } from "./cli-output.js";
import { createIMessageRpcClient } from "./client.js";
import { extractMarkdownFormatRuns } from "./markdown-format.js";
import { resolveIMessageMessageId as resolveIMessageMessageIdImpl } from "./monitor-reply-cache.js";
import {
normalizeDirectChatIdentifier,
resolveIMessageMessageId as resolveIMessageMessageIdImpl,
} from "./monitor-reply-cache.js";
import type { IMessageTarget } from "./targets.js";
type CliRunOptions = {
@@ -115,7 +118,7 @@ function chatListCacheSet(
* forms — the action surface synthesizes `iMessage;-;<phone>` from a
* handle target, while imsg's chats.list returns `identifier: <phone>`
* and `guid: any;-;<phone>`. Comparing the raw strings would falsely
* miss the match. Mirror of the same helper in monitor-reply-cache.ts.
* miss the match.
*/
export function normalizeDirectChatIdentifierForTest(raw: string): string {
return normalizeDirectChatIdentifier(raw);
@@ -128,17 +131,6 @@ export function findChatGuidForTest(
return findChatGuid(chats, target);
}
function normalizeDirectChatIdentifier(raw: string): string {
const trimmed = raw.trim();
const lowered = trimmed.toLowerCase();
for (const prefix of ["imessage;-;", "sms;-;", "any;-;"]) {
if (lowered.startsWith(prefix)) {
return trimmed.slice(prefix.length);
}
}
return trimmed;
}
function findChatGuid(
chats: readonly Record<string, unknown>[],
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,

View File

@@ -256,7 +256,7 @@ function hasChatScope(ctx?: IMessageChatContext): boolean {
* so comparing the raw strings would falsely flag the same chat as a
* cross-chat target. Normalize both sides to the bare suffix.
*/
function normalizeDirectChatIdentifier(raw: string): string {
export function normalizeDirectChatIdentifier(raw: string): string {
const trimmed = raw.trim();
const lowered = trimmed.toLowerCase();
for (const prefix of ["imessage;-;", "sms;-;", "any;-;"]) {

View File

@@ -38,12 +38,40 @@ const debouncerControl = vi.hoisted(() => ({
holdEntries: false,
entries: [] as unknown[],
flush: undefined as undefined | (() => Promise<void>),
flushEach: undefined as undefined | (() => Promise<void>),
reset() {
this.holdEntries = false;
this.entries = [];
this.flush = undefined;
this.flushEach = undefined;
},
}));
const createChannelInboundDebouncerMock = vi.hoisted(() =>
vi.fn((opts: { onFlush: (entries: unknown[]) => Promise<void> }) => ({
debouncer: {
enqueue: async (entry: unknown) => {
if (!debouncerControl.holdEntries) {
await opts.onFlush([entry]);
return;
}
debouncerControl.entries.push(entry);
debouncerControl.flush = async () => {
const entries = debouncerControl.entries.splice(0);
await opts.onFlush(entries);
};
// Flush each collected entry as its own single-entry bucket, modeling
// the real non-debounced path (shouldDebounceTextInbound is mocked to
// false here) where every row dispatches individually.
debouncerControl.flushEach = async () => {
const entries = debouncerControl.entries.splice(0);
for (const queued of entries) {
await opts.onFlush([queued]);
}
};
},
},
})),
);
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
waitForTransportReady: waitForTransportReadyMock,
@@ -63,21 +91,7 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
createChannelInboundDebouncer: vi.fn((opts) => ({
debouncer: {
enqueue: async (entry: unknown) => {
if (!debouncerControl.holdEntries) {
await opts.onFlush([entry]);
return;
}
debouncerControl.entries.push(entry);
debouncerControl.flush = async () => {
const entries = debouncerControl.entries.splice(0);
await opts.onFlush(entries);
};
},
},
})),
createChannelInboundDebouncer: createChannelInboundDebouncerMock,
shouldDebounceTextInbound: vi.fn(() => false),
};
});
@@ -108,6 +122,7 @@ describe("iMessage monitor last-route updates", () => {
readChannelAllowFromStoreMock.mockReset().mockResolvedValue([]);
recordInboundSessionMock.mockClear();
dispatchInboundMessageMock.mockClear();
createChannelInboundDebouncerMock.mockClear();
debouncerControl.reset();
clearCachedIMessagePrivateApiStatus();
});
@@ -213,6 +228,7 @@ describe("iMessage monitor last-route updates", () => {
config: {
channels: {
imessage: {
coalesceSameSenderDms: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
@@ -382,6 +398,7 @@ describe("iMessage monitor last-route updates", () => {
config: {
channels: {
imessage: {
coalesceSameSenderDms: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
@@ -1204,7 +1221,6 @@ describe("iMessage monitor last-route updates", () => {
is_from_me: false,
text: `missed during downtime ${id}`,
is_group: false,
balloon_bundle_id: "com.apple.messages.Handwriting",
created_at: thirtyMinAgo,
},
},
@@ -1238,7 +1254,7 @@ describe("iMessage monitor last-route updates", () => {
await vi.waitFor(() => {
expect(debouncerControl.entries).toHaveLength(2);
});
await debouncerControl.flush?.();
await debouncerControl.flushEach?.();
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
});
@@ -1277,7 +1293,6 @@ describe("iMessage monitor last-route updates", () => {
is_from_me: false,
text: `missed during downtime ${id}`,
is_group: false,
balloon_bundle_id: "com.apple.messages.Handwriting",
created_at: thirtyMinAgo,
},
},
@@ -1307,7 +1322,7 @@ describe("iMessage monitor last-route updates", () => {
expect(debouncerControl.entries).toHaveLength(2);
});
debouncerControl.entries.reverse();
await debouncerControl.flush?.();
await debouncerControl.flushEach?.();
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
});
@@ -1408,12 +1423,10 @@ describe("iMessage monitor last-route updates", () => {
expect(dispatchParams?.ctx.To).not.toBe("imessage:+15550001111");
});
it("legacy-merges coalesce buckets when imsg emits no balloon metadata (older builds)", async () => {
// Back-compat: older imsg builds emit no balloon_bundle_id, so a Dump + URL
// split-send arrives as two fieldless rows. We cannot structurally tell that
// apart from separate sends, so we preserve the pre-metadata merge rather
// than regress split-send users to two turns. Removed once imsg coalesces
// upstream (openclaw/imsg#141, tracked by #91243).
it("merges a command row with the following URL balloon row", async () => {
// Apple's command+URL composition can arrive as a command row followed by a
// URL-preview balloon row. The opt-in coalescer keeps the pair as one agent
// turn and uses balloon metadata to avoid collapsing ordinary rows.
debouncerControl.holdEntries = true;
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
@@ -1426,97 +1439,18 @@ describe("iMessage monitor last-route updates", () => {
}),
waitForClose: vi.fn(async () => {
// Fresh dates relative to now so the stale-backlog age fence lets the
// live split-send through to the coalescer.
// live rows through to the debouncer.
for (const row of [
{
id: 91,
guid: "LIVE-GUID-91",
text: "Dump",
text: "summarize",
created_at: new Date(Date.now() - 2000).toISOString(),
},
{
id: 92,
guid: "LIVE-GUID-92",
text: "https://example.com",
created_at: new Date(Date.now() - 1000).toISOString(),
},
]) {
onNotification?.({
method: "message",
params: {
message: {
...row,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
is_group: false,
},
},
});
}
await vi.waitFor(() => {
expect(debouncerControl.flush).toBeDefined();
});
await debouncerControl.flush?.();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
coalesceSameSenderDms: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
messages: { inbound: { debounceMs: 2500 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
const mergedBody = dispatchInboundMessageMock.mock.calls[0]?.[0].ctx.Body ?? "";
expect(mergedBody).toContain("Dump");
expect(mergedBody).toContain("https://example.com");
});
it("merges coalesce buckets when imsg marks the URL balloon row structurally", async () => {
debouncerControl.holdEntries = true;
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
// Fresh dates relative to now so the stale-backlog age fence lets the
// live split-send through to the coalescer.
for (const row of [
{
id: 93,
guid: "LIVE-GUID-93",
text: "Dump",
created_at: new Date(Date.now() - 2000).toISOString(),
},
{
id: 94,
guid: "LIVE-GUID-94",
text: "https://example.com",
text: "https://example.com/article",
balloon_bundle_id: "com.apple.messages.URLBalloonProvider",
created_at: new Date(Date.now() - 1000).toISOString(),
},
@@ -1560,15 +1494,388 @@ describe("iMessage monitor last-route updates", () => {
sendReadReceipts: false,
},
},
messages: { inbound: { debounceMs: 2500 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
const debouncerOptions = createChannelInboundDebouncerMock.mock.calls.at(-1)?.[0] as
| { debounceMsOverride?: number }
| undefined;
expect(debouncerOptions?.debounceMsOverride).toBe(7000);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessageMock.mock.calls[0]?.[0].ctx.Body).toContain(
"Dump https://example.com",
);
const mergedBody = dispatchInboundMessageMock.mock.calls[0]?.[0].ctx.Body ?? "";
expect(mergedBody).toContain("summarize");
expect(mergedBody).toContain("https://example.com/article");
});
it("keeps ordinary buffered DMs separate after balloon metadata is observed", async () => {
debouncerControl.holdEntries = true;
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
for (const row of [
{
id: 101,
guid: "LIVE-GUID-101",
text: "handwriting",
balloon_bundle_id: "com.apple.messages.HandwritingProvider",
created_at: new Date(Date.now() - 3000).toISOString(),
},
{
id: 102,
guid: "LIVE-GUID-102",
text: "first thought",
created_at: new Date(Date.now() - 2000).toISOString(),
},
{
id: 103,
guid: "LIVE-GUID-103",
text: "second thought",
created_at: new Date(Date.now() - 1000).toISOString(),
},
]) {
onNotification?.({
method: "message",
params: {
message: {
...row,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
is_group: false,
},
},
});
}
await vi.waitFor(() => {
expect(debouncerControl.flush).toBeDefined();
});
await debouncerControl.flush?.();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
coalesceSameSenderDms: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(3);
const bodies = dispatchInboundMessageMock.mock.calls.map((call) => call[0].ctx.Body ?? "");
expect(bodies.some((body) => body.includes("handwriting"))).toBe(true);
expect(bodies.some((body) => body.includes("first thought"))).toBe(true);
expect(bodies.some((body) => body.includes("second thought"))).toBe(true);
});
it("uses stale balloon rows as metadata support without dispatching them", async () => {
debouncerControl.holdEntries = true;
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
for (const row of [
{
id: 201,
guid: "STALE-BALLOON-GUID-201",
text: "old handwriting",
balloon_bundle_id: "com.apple.messages.HandwritingProvider",
created_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
},
{
id: 202,
guid: "LIVE-GUID-202",
text: "first fresh thought",
created_at: new Date(Date.now() - 2000).toISOString(),
},
{
id: 203,
guid: "LIVE-GUID-203",
text: "second fresh thought",
created_at: new Date(Date.now() - 1000).toISOString(),
},
]) {
onNotification?.({
method: "message",
params: {
message: {
...row,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
is_group: false,
},
},
});
}
await vi.waitFor(() => {
expect(debouncerControl.flush).toBeDefined();
});
await debouncerControl.flush?.();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
coalesceSameSenderDms: true,
dbPath: path.join(os.tmpdir(), `openclaw-missing-chat-${Date.now()}.db`),
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
const bodies = dispatchInboundMessageMock.mock.calls.map((call) => call[0].ctx.Body ?? "");
expect(bodies.some((body) => body.includes("old handwriting"))).toBe(false);
expect(bodies.some((body) => body.includes("first fresh thought"))).toBe(true);
expect(bodies.some((body) => body.includes("second fresh thought"))).toBe(true);
});
it("does not merge unrelated buffered rows into a following URL split-send", async () => {
debouncerControl.holdEntries = true;
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
for (const row of [
{
id: 111,
guid: "LIVE-GUID-111",
text: "unrelated thought",
created_at: new Date(Date.now() - 3000).toISOString(),
},
{
id: 112,
guid: "LIVE-GUID-112",
text: "summarize",
created_at: new Date(Date.now() - 2000).toISOString(),
},
{
id: 113,
guid: "LIVE-GUID-113",
text: "https://example.com/article",
balloon_bundle_id: "com.apple.messages.URLBalloonProvider",
created_at: new Date(Date.now() - 1000).toISOString(),
},
]) {
onNotification?.({
method: "message",
params: {
message: {
...row,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
is_group: false,
},
},
});
}
await vi.waitFor(() => {
expect(debouncerControl.flush).toBeDefined();
});
await debouncerControl.flush?.();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
coalesceSameSenderDms: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
const bodies = dispatchInboundMessageMock.mock.calls.map((call) => call[0].ctx.Body ?? "");
expect(bodies[0]).toContain("unrelated thought");
expect(bodies[0]).not.toContain("summarize");
expect(bodies[1]).toContain("summarize");
expect(bodies[1]).toContain("https://example.com/article");
expect(bodies[1]).not.toContain("unrelated thought");
});
it("does not merge unrelated buffered rows into an already-complete URL balloon message", async () => {
debouncerControl.holdEntries = true;
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
for (const row of [
{
id: 211,
guid: "LIVE-GUID-211",
text: "unrelated thought",
created_at: new Date(Date.now() - 2000).toISOString(),
},
{
id: 212,
guid: "LIVE-GUID-212",
text: "summarize https://example.com/article",
balloon_bundle_id: "com.apple.messages.URLBalloonProvider",
created_at: new Date(Date.now() - 1000).toISOString(),
},
]) {
onNotification?.({
method: "message",
params: {
message: {
...row,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
is_group: false,
},
},
});
}
await vi.waitFor(() => {
expect(debouncerControl.flush).toBeDefined();
});
await debouncerControl.flush?.();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
coalesceSameSenderDms: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
const bodies = dispatchInboundMessageMock.mock.calls.map((call) => call[0].ctx.Body ?? "");
expect(bodies[0]).toContain("unrelated thought");
expect(bodies[0]).not.toContain("summarize");
expect(bodies[1]).toContain("summarize");
expect(bodies[1]).toContain("https://example.com/article");
expect(bodies[1]).not.toContain("unrelated thought");
});
it("respects explicit iMessage inbound debounce timing", async () => {
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async () => client as never);
await monitorIMessageProvider({
config: {
channels: {
imessage: {
coalesceSameSenderDms: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
messages: { inbound: { byChannel: { imessage: 0 } } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
const debouncerOptions = createChannelInboundDebouncerMock.mock.calls.at(-1)?.[0] as
| { debounceMsOverride?: number }
| undefined;
expect(debouncerOptions?.debounceMsOverride).toBeUndefined();
});
});

View File

@@ -2,8 +2,8 @@
import { describe, expect, it } from "vitest";
import {
combineIMessagePayloads,
hasIMessageUrlBalloonBundleID,
IMESSAGE_URL_BALLOON_BUNDLE_ID,
isStandaloneIMessageUrlPreviewPayload,
MAX_COALESCED_ATTACHMENTS,
MAX_COALESCED_ENTRIES,
MAX_COALESCED_TEXT_CHARS,
@@ -24,52 +24,6 @@ const makePayload = (overrides: Partial<IMessagePayload> = {}): IMessagePayload
});
describe("combineIMessagePayloads", () => {
it("recognizes URL balloon rows from imsg structural metadata", () => {
const text = makePayload({ text: "Dump" });
const balloon = makePayload({
text: "https://example.com/article",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
});
expect(hasIMessageUrlBalloonBundleID(text)).toBe(false);
expect(hasIMessageUrlBalloonBundleID(balloon)).toBe(true);
// A real URL split-send merges regardless of the session capability latch.
expect(shouldCombineIMessagePayloadBucket([text, balloon], false)).toBe(true);
expect(shouldCombineIMessagePayloadBucket([text, balloon], true)).toBe(true);
});
it("falls back to a legacy merge when the build has never emitted balloon metadata (older imsg)", () => {
// Older imsg builds emit no balloon_bundle_id at all. We cannot tell a URL
// split-send from separate sends, so we preserve the pre-metadata merge
// rather than regress split-send users to two turns. Back-compat path,
// removed once imsg coalesces upstream (openclaw/imsg#141, tracked by #91243).
const text = makePayload({ text: "Dump" });
const url = makePayload({ text: "https://example.com/article" });
expect(shouldCombineIMessagePayloadBucket([text, url], false)).toBe(true);
});
it("keeps a plain bucket separate once the build is known to emit balloon metadata", () => {
// Capability latch is true (a prior row this session carried metadata), so a
// plain bucket with no URL marker is genuinely not a split-send. imsg omits
// the field for plain rows, so this case is indistinguishable per-bucket and
// depends on the session-level signal.
const a = makePayload({ text: "first" });
const b = makePayload({ text: "second" });
expect(shouldCombineIMessagePayloadBucket([a, b], true)).toBe(false);
});
it("keeps a bucket separate when imsg exposes balloon metadata in the bucket but no URL marker", () => {
// New imsg surfaced balloon metadata in this very bucket, proving this build
// emits the field, but the bucket is not a URL split-send. Keep separate even
// if the latch had not flipped yet.
const text = makePayload({ text: "hi" });
const nonUrlBalloon = makePayload({
text: "tap to vote",
balloon_bundle_id: "com.apple.messages.MSMessageExtensionBalloonPlugin",
});
expect(shouldCombineIMessagePayloadBucket([text, nonUrlBalloon], false)).toBe(false);
});
it("throws on empty input", () => {
expect(() => combineIMessagePayloads([])).toThrow(
"combineIMessagePayloads: cannot combine empty payloads",
@@ -83,23 +37,22 @@ describe("combineIMessagePayloads", () => {
expect(result.guid).toBe("solo");
});
it("merges Dump + URL split-send into one payload anchored on the first GUID", () => {
const text = makePayload({
it("merges two same-sender rows into one payload anchored on the first GUID", () => {
const first = makePayload({
id: 41,
text: "Dump",
text: "summarize",
guid: "row-1",
created_at: "2025-01-01T00:00:00Z",
});
const balloon = makePayload({
const second = makePayload({
id: 42,
text: "https://example.com/article",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
guid: "row-2",
created_at: "2025-01-01T00:00:01.500Z",
});
const merged = combineIMessagePayloads([text, balloon]);
const merged = combineIMessagePayloads([first, second]);
expect(merged.text).toBe("Dump https://example.com/article");
expect(merged.text).toBe("summarize https://example.com/article");
expect(merged.guid).toBe("row-1");
expect(merged.created_at).toBe("2025-01-01T00:00:01.500Z");
expect(merged.coalescedMessageGuids).toEqual(["row-1", "row-2"]);
@@ -209,3 +162,87 @@ describe("combineIMessagePayloads", () => {
expect(MAX_COALESCED_ENTRIES).toBeGreaterThan(1);
});
});
describe("isStandaloneIMessageUrlPreviewPayload", () => {
it("matches URL balloon rows that only carry the preview URL", () => {
expect(
isStandaloneIMessageUrlPreviewPayload(
makePayload({
text: "https://example.com/article",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
}),
),
).toBe(true);
});
it("matches scheme-less www URL preview rows", () => {
expect(
isStandaloneIMessageUrlPreviewPayload(
makePayload({
text: "www.example.com/article",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
}),
),
).toBe(true);
});
it("does not match already-complete URL balloon messages with text context", () => {
expect(
isStandaloneIMessageUrlPreviewPayload(
makePayload({
text: "summarize https://example.com/article",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
}),
),
).toBe(false);
});
it("does not match non-URL balloon payloads", () => {
expect(
isStandaloneIMessageUrlPreviewPayload(
makePayload({
text: "https://example.com/article",
balloon_bundle_id: "com.apple.messages.HandwritingProvider",
}),
),
).toBe(false);
});
});
describe("shouldCombineIMessagePayloadBucket", () => {
it("combines a command row with a structurally marked URL balloon row", () => {
const command = makePayload({ text: "summarize", guid: "row-1" });
const preview = makePayload({
text: "https://example.com/article",
guid: "row-2",
balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID,
});
expect(shouldCombineIMessagePayloadBucket([command, preview], true)).toBe(true);
});
it("keeps ordinary buffered rows separate once the bridge emits balloon metadata", () => {
const first = makePayload({ text: "first thought", guid: "row-1" });
const second = makePayload({ text: "second thought", guid: "row-2" });
expect(shouldCombineIMessagePayloadBucket([first, second], true)).toBe(false);
});
it("keeps non-URL balloon rows separate", () => {
const first = makePayload({ text: "first thought", guid: "row-1" });
const second = makePayload({
text: "second thought",
guid: "row-2",
balloon_bundle_id: "com.apple.messages.HandwritingProvider",
});
expect(shouldCombineIMessagePayloadBucket([first, second], false)).toBe(false);
});
it("falls back to combining old bridge buckets with no balloon metadata", () => {
const command = makePayload({ text: "summarize", guid: "row-1" });
const url = makePayload({ text: "https://example.com/article", guid: "row-2" });
expect(shouldCombineIMessagePayloadBucket([command, url], false)).toBe(true);
});
});

View File

@@ -1,10 +1,10 @@
// Imessage plugin module implements coalesce behavior.
// Imessage plugin module implements the same-sender inbound debounce merge.
import type { IMessagePayload } from "./types.js";
// Keep the coalescing contract narrow (caps, ID tracking, reply-context
// preference) so a future SDK lift into `openclaw/plugin-sdk/channel-inbound`
// is a mechanical extraction instead of a behavioral redesign. Apple's
// split-send pipeline is the behavior this protects.
// Keep the merge contract narrow (caps, ID tracking, reply-context preference)
// so a future SDK lift into `openclaw/plugin-sdk/channel-inbound` is a
// mechanical extraction instead of a behavioral redesign. Apple's URL-preview
// split-send pipeline is the iMessage-only behavior this still protects.
/**
* Bounds on the merged output when multiple inbound iMessage payloads are
@@ -22,52 +22,57 @@ export function hasIMessageUrlBalloonBundleID(payload: IMessagePayload): boolean
return payload.balloon_bundle_id === IMESSAGE_URL_BALLOON_BUNDLE_ID;
}
// imsg only emits `balloon_bundle_id` for rows that actually carry a balloon
// (the nil case is omitted on the wire), so a present, non-empty value is the
// signal that this build exposes balloon metadata at all.
function isSingleUrlToken(text: string): boolean {
if (/\s/.test(text)) {
return false;
}
if (/^www\.[^\s.]+\.[^\s]+$/i.test(text)) {
return true;
}
try {
const url = new URL(text);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function isStandaloneIMessageUrlPreviewPayload(payload: IMessagePayload): boolean {
if (!hasIMessageUrlBalloonBundleID(payload)) {
return false;
}
const text = (payload.text ?? "").trim();
return text.length === 0 || isSingleUrlToken(text);
}
// imsg omits `balloon_bundle_id` for non-balloon rows, so a present value is
// the session signal that this bridge build exposes structural balloon
// metadata. Once latched, missing URL metadata is meaningful.
export function hasIMessageBalloonMetadata(payload: IMessagePayload): boolean {
return typeof payload.balloon_bundle_id === "string" && payload.balloon_bundle_id.length > 0;
}
/**
* Decide whether a debounced same-sender bucket should merge into one turn.
* Decide whether a debounced same-sender iMessage bucket should merge.
*
* `buildEmitsBalloonMetadata` is a session-level capability latch: once any
* inbound row from this imsg build has carried balloon metadata, absence of a
* URL marker is meaningful (the row genuinely is not a URL split-send), so we
* can keep ordinary buffered DMs separate. It must be session-scoped, not
* per-bucket: imsg omits `balloon_bundle_id` on the wire for non-balloon rows,
* so a bucket of plain text rows looks identical on old and new builds.
* URL-preview rows are merged with their preceding command row so Apple's
* command+URL split-send still reaches the agent as one turn. Once a bridge
* session has emitted balloon metadata, ordinary same-sender DMs without the
* URL marker flush separately instead of being collapsed.
*/
export function shouldCombineIMessagePayloadBucket(
payloads: readonly IMessagePayload[],
buildEmitsBalloonMetadata: boolean,
): boolean {
// Precise path: a real Apple URL-preview split-send carries the URL-balloon
// marker on the preview row — merge it into one turn.
if (payloads.some(hasIMessageUrlBalloonBundleID)) {
return true;
}
// Metadata-capable build (observed earlier this session or in this bucket):
// the missing URL marker is trustworthy, so keep ordinary buffered DMs as
// separate turns. This is the precision the structural gate exists for.
if (buildEmitsBalloonMetadata || payloads.some(hasIMessageBalloonMetadata)) {
return false;
}
// Back-compat (remove once imsg coalesces split-sends upstream — see
// openclaw/imsg#141, tracked by #91243): a build that has never emitted any
// balloon metadata cannot structurally tell a `Dump <url>` split-send from
// separate sends. Preserve the pre-metadata merge so split-send users do not
// regress to two turns on a released imsg that lacks the field.
//
// This never merges more than the shipped behavior already did: with
// `coalesceSameSenderDms` enabled, `main` debounces every same-sender DM and
// merges each multi-entry bucket unconditionally. So an unlatched session
// (old build, or a metadata-capable build before its first balloon row) is
// identical to today, not a new regression. Flushing these buckets instead
// would re-break old-imsg split-sends — the very case this guards. Fully
// closing the pre-latch window needs an imsg-advertised capability flag, which
// is part of the upstream #141 work.
// Older imsg builds expose no balloon metadata, so a command+URL split-send
// is indistinguishable from two ordinary text rows. Keep the internal fallback
// until imsg advertises upstream coalescing for that exact shape.
return true;
}
@@ -86,9 +91,8 @@ export type CoalescedIMessagePayload = IMessagePayload & {
/**
* Combine consecutive same-sender iMessage payloads into a single payload for
* downstream dispatch. Used when the debouncer flushes a bucket containing
* more than one event — e.g. Apple's split-send for `Dump https://example.com`
* arriving as two separate `chat.db` rows ~0.8-2.0 s apart.
* downstream dispatch. Used for Apple's URL-preview split-send, and for the
* general inbound debounce (`messages.inbound`, off by default) when configured.
*
* The first payload anchors the merged shape (preserving its GUID for reply
* threading). Text is concatenated with deduplication, attachments are merged

View File

@@ -70,6 +70,8 @@ import { advanceIMessageCatchupCursor, resolveCatchupConfig } from "./catchup.js
import {
combineIMessagePayloads,
hasIMessageBalloonMetadata,
hasIMessageUrlBalloonBundleID,
isStandaloneIMessageUrlPreviewPayload,
shouldCombineIMessagePayloadBucket,
} from "./coalesce.js";
import { repairIMessageConversationAnchor } from "./conversation-repair.js";
@@ -113,12 +115,31 @@ const APPROVAL_REACTION_POLL_INTERVAL_MS = 2_000;
const APPROVAL_REACTION_DISCOVERY_INTERVAL_MS = 60_000;
const IMESSAGE_TYPING_KEEPALIVE_INTERVAL_MS = 8_000;
const IMESSAGE_TYPING_KEEPALIVE_MAX_DURATION_MS = 10 * 60_000;
const IMESSAGE_SPLIT_SEND_COMPAT_DEBOUNCE_MS = 7_000;
type IMessageTypingController = Parameters<NonNullable<GetReplyOptions["onTypingController"]>>[0];
function resolveConfiguredIMessageTypingMode(cfg: OpenClawConfig) {
return cfg.session?.typingMode ?? cfg.agents?.defaults?.typingMode;
}
function resolveIMessageSplitSendCompatDebounceMs(
cfg: OpenClawConfig,
coalesceSameSenderDms: boolean,
): number | undefined {
if (!coalesceSameSenderDms) {
return undefined;
}
const inbound = cfg.messages?.inbound;
const channelOverride = inbound?.byChannel?.imessage;
if (typeof channelOverride === "number" && Number.isFinite(channelOverride)) {
return undefined;
}
if (typeof inbound?.debounceMs === "number" && Number.isFinite(inbound.debounceMs)) {
return undefined;
}
return IMESSAGE_SPLIT_SEND_COMPAT_DEBOUNCE_MS;
}
function isIMessagePluginPayloadAttachment(attachment: {
original_path?: string | null;
transfer_name?: string | null;
@@ -457,23 +478,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
: recoveryCursorRowid
: recoveryBoundaryRowid;
// When `coalesceSameSenderDms` is enabled and the user has not set an
// explicit inbound debounce for this channel, widen the window to 2500 ms.
// Apple's split-send for `<command> <URL>` arrives ~0.8-2.0 s apart on most
// setups, so the legacy 0 ms default would flush the command alone before
// the URL row reaches the debouncer.
const coalesceSameSenderDms = imessageCfg.coalesceSameSenderDms === true;
const inboundCfg = cfg.messages?.inbound;
const hasExplicitInboundDebounce =
typeof inboundCfg?.debounceMs === "number" ||
typeof inboundCfg?.byChannel?.imessage === "number";
const debounceMsOverride =
coalesceSameSenderDms && !hasExplicitInboundDebounce ? 2500 : undefined;
const debounceMsOverride = resolveIMessageSplitSendCompatDebounceMs(cfg, coalesceSameSenderDms);
// Session capability latch: flips true once any inbound row from this imsg
// build carries balloon metadata. The coalesce flush gate needs a build-level
// (not per-bucket) signal because imsg omits `balloon_bundle_id` for plain
// rows, so a bucket of plain text looks identical on old and new builds.
// signal because imsg omits `balloon_bundle_id` for plain rows.
let imsgEmitsBalloonMetadata = false;
let recoveryCursorHoldBeforeRowid: number | null = null;
let latestAdvancedRecoveryCursorRowid = recoveryCursorRowid ?? -1;
@@ -585,8 +594,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
message: IMessagePayload;
// Exact replay-guard key claimed for this row at ingestion (GUID or, for a
// GUID-less row, the composite fallback). Carried through so flush commits
// or releases the same key it claimed, even after coalescing rewrites the
// payload identity. null when the row had no derivable key (fail open).
// or releases the same key it claimed, even after a debounce merge rewrites
// the payload identity. null when the row had no derivable key (fail open).
replayKey: string | null;
}>({
cfg,
@@ -603,10 +612,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
? `chat:${msg.chat_id}`
: (msg.chat_guid ?? msg.chat_identifier ?? "unknown");
// With coalesceSameSenderDms enabled, DMs key on chat:sender so Apple's
// split text row and URL-balloon row land in the same bucket. The flush
// path still requires imsg's structural balloon metadata before merging.
// Group chats keep the legacy key to preserve multi-user turn structure.
if (coalesceSameSenderDms && msg.is_group !== true) {
return `imessage:${accountInfo.accountId}:dm:${conversationId}:${sender}`;
}
@@ -623,15 +628,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
return false;
}
// Hold opt-in DMs long enough for a following URL-balloon row to arrive.
// The flush gate (shouldCombineIMessagePayloadBucket) decides merge vs.
// separate: it merges precisely on imsg's balloon marker, and falls back
// to a legacy merge only when the build emits no balloon metadata at all.
// Opt-in DM coalescing holds rows long enough for Apple's command+URL
// split-send to arrive. Group chats keep instant per-message dispatch.
if (coalesceSameSenderDms) {
return msg.is_group !== true;
}
// Legacy gate: text-only, no control commands, no media.
// General same-sender inbound debounce: text-only, no control commands,
// no media. Off by default unless messages.inbound is configured.
return shouldDebounceTextInbound({
text: msg.text,
cfg,
@@ -644,7 +648,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
if (entries.length === 0) {
return;
}
// Dispatch one unit (a single row or a coalesced bucket), then commit the
// Dispatch one unit (a single row or a merged bucket), then commit the
// exact replay keys that were claimed at ingestion, or release them if
// dispatch throws so a transient failure can retry on a later re-emit. Per
// unit so a failure in one bucket entry cannot strand another's claim.
@@ -687,13 +691,47 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
}
return;
}
// The bucket-level gate only says this window contains URL-balloon work.
// Standalone URL preview rows merge with the immediately preceding row;
// already-complete URL messages flush any pending ordinary row first.
if (messages.some(hasIMessageUrlBalloonBundleID)) {
let pending: { message: IMessagePayload; replayKey: string | null } | null = null;
for (const entry of entries) {
if (isStandaloneIMessageUrlPreviewPayload(entry.message) && pending) {
const unitEntries = [pending, entry];
await dispatchUnit(
unitEntries,
combineIMessagePayloads(unitEntries.map((e) => e.message)),
);
pending = null;
continue;
}
if (hasIMessageUrlBalloonBundleID(entry.message)) {
if (pending) {
await dispatchUnit([pending], pending.message);
pending = null;
}
await dispatchUnit([entry], entry.message);
continue;
}
if (pending) {
await dispatchUnit([pending], pending.message);
}
pending = entry;
}
if (pending) {
await dispatchUnit([pending], pending.message);
}
return;
}
const combined = combineIMessagePayloads(messages);
if (shouldLogVerbose()) {
const text = combined.text ?? "";
const preview = text.slice(0, 50);
const ellipsis = text.length > 50 ? "..." : "";
logVerbose(`[imessage] coalesced ${entries.length} messages: "${preview}${ellipsis}"`);
logVerbose(
`[imessage] merged ${entries.length} debounced messages: "${preview}${ellipsis}"`,
);
}
await dispatchUnit(entries, combined);
},
@@ -1332,8 +1370,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
runtime.error?.(`imessage: dropping malformed RPC message payload (keys=${shape})`);
return;
}
// Latch build capability from any row that carries balloon metadata so the
// coalesce flush gate can trust a missing URL marker on later plain buckets.
if (!imsgEmitsBalloonMetadata && hasIMessageBalloonMetadata(message)) {
imsgEmitsBalloonMetadata = true;
}

View File

@@ -68,28 +68,6 @@ describe("parseIMessageNotification", () => {
expect(parsed?.reacted_to_guid).toBe("target-guid");
});
it("preserves imsg balloon bundle metadata when present", () => {
const parsed = parseIMessageNotification({
message: {
id: 1,
guid: "link-preview-guid",
chat_id: 2,
sender: "+10000000000",
is_from_me: false,
text: "https://example.com/article",
balloon_bundle_id: "com.apple.messages.URLBalloonProvider",
attachments: null,
chat_identifier: null,
chat_guid: null,
chat_name: null,
participants: null,
is_group: false,
},
});
expect(parsed?.balloon_bundle_id).toBe("com.apple.messages.URLBalloonProvider");
});
it("accepts iMessage attachment transfer_name and uti metadata", () => {
const parsed = parseIMessageNotification({
message: {
@@ -122,4 +100,27 @@ describe("parseIMessageNotification", () => {
uti: "com.apple.messages.pluginPayloadAttachment",
});
});
it("preserves imsg balloon bundle metadata when present", () => {
const parsed = parseIMessageNotification({
message: {
id: 1,
guid: "link-preview-guid",
chat_id: 2,
sender: "+10000000000",
destination_caller_id: null,
balloon_bundle_id: "com.apple.messages.URLBalloonProvider",
is_from_me: false,
text: "https://example.com/article",
attachments: null,
chat_identifier: null,
chat_guid: null,
chat_name: null,
participants: null,
is_group: false,
},
});
expect(parsed?.balloon_bundle_id).toBe("com.apple.messages.URLBalloonProvider");
});
});

View File

@@ -108,6 +108,15 @@ describe("slash-commands", () => {
).toEqual(["oc_model", "oc_models"]);
});
it("registers the queue command mapped to the core /queue directive", () => {
const queueSpec = DEFAULT_COMMAND_SPECS.find((spec) => spec.trigger === "oc_queue");
expect(queueSpec?.originalName).toBe("queue");
const triggerMap = new Map<string, string>([["oc_queue", "queue"]]);
expect(resolveCommandText("oc_queue", " collect drop:summarize ", triggerMap)).toBe(
"/queue collect drop:summarize",
);
});
it("normalizes callback path in slash config", () => {
const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" });
expect(config.callbackPath).toBe("/api/channels/mattermost/command");

View File

@@ -172,6 +172,13 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
autoComplete: true,
autoCompleteHint: "[on|off]",
},
{
trigger: "oc_queue",
originalName: "queue",
description: "Adjust active-run queue behavior",
autoComplete: true,
autoCompleteHint: "[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
},
];
// ─── Command registration ────────────────────────────────────────────────────

View File

@@ -34,6 +34,7 @@ import {
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
SESSION_SEEN_HASHES_PER_CHUNK,
normalizeMemoryCoreWorkspaceKey,
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
} from "./dreaming-state.js";
@@ -542,11 +543,6 @@ type SessionIngestionCollectionResult = {
changed: boolean;
};
function normalizeWorkspaceKey(workspaceDir: string): string {
const resolved = path.resolve(workspaceDir).replace(/\\/g, "/");
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
export function normalizeSessionIngestionState(raw: unknown): SessionIngestionState {
const record = asRecord(raw);
const filesRaw = asRecord(record?.files);
@@ -749,7 +745,7 @@ function resolveSessionAgentsForWorkspace(params: {
if (!cfg) {
return [];
}
const target = normalizeWorkspaceKey(workspaceDir);
const target = normalizeMemoryCoreWorkspaceKey(workspaceDir);
const workspaces = resolveMemoryDreamingWorkspaces(
cfg as Parameters<typeof resolveMemoryDreamingWorkspaces>[0],
{
@@ -757,7 +753,9 @@ function resolveSessionAgentsForWorkspace(params: {
primaryAgentId: "main",
},
);
const match = workspaces.find((entry) => normalizeWorkspaceKey(entry.workspaceDir) === target);
const match = workspaces.find(
(entry) => normalizeMemoryCoreWorkspaceKey(entry.workspaceDir) === target,
);
if (!match) {
return [];
}

View File

@@ -834,17 +834,6 @@ function isProcessLikelyAlive(pid: number): boolean {
}
}
async function canStealStaleLock(lockPath: string): Promise<boolean> {
const ownerPid = await fs
.readFile(lockPath, "utf-8")
.then((raw) => parseLockOwnerPid(raw))
.catch(() => null);
if (ownerPid === null) {
return true;
}
return !isProcessLikelyAlive(ownerPid);
}
async function sleep(ms: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
@@ -2813,7 +2802,6 @@ export async function removeGroundedShortTermCandidates(params: {
export const testing = {
parseLockOwnerPid,
canStealStaleLock,
isProcessLikelyAlive,
readRecallStore: readStore,
readPhaseSignalStore,

View File

@@ -1208,6 +1208,7 @@ describe("qa cli runtime", () => {
expectFields(mockFirstObjectArg(runQaSuite), {
scenarioIds: [
"runtime-long-context-cache-stability",
"runtime-soak-100-turn",
"runtime-tool-image-generate",
"runtime-tool-memory-add",

View File

@@ -3146,6 +3146,47 @@ describe("qa mock openai server", () => {
expect(String(toolPlanOutput.arguments)).toContain("current");
});
it("summarizes QA tool-search bridge outputs with the nested plugin result marker", async () => {
const server = await startMockServer();
const targetTool = "fake_plugin_tool_17";
const response = await postResponses(server, {
stream: false,
input: [
makeUserInput(
`tool search qa check target=${targetTool}. Call exactly that tool once and then summarize.`,
),
{
type: "function_call_output",
call_id: "call_tool_search_code_1",
output: JSON.stringify({
ok: true,
value: {
tool: {
id: `openclaw:tool-search-e2e-fixture:${targetTool}`,
source: "openclaw",
sourceName: "tool-search-e2e-fixture",
name: targetTool,
description: "x".repeat(260),
},
result: {
content: [
{
type: "text",
text: `FAKE_PLUGIN_OK ${targetTool} {"marker":"code"}`,
},
],
},
},
}),
},
],
});
expect(response.status).toBe(200);
expect(outputText(await response.json())).toBe(`FAKE_PLUGIN_OK ${targetTool}`);
});
it("plans QA tool-search failure calls with denied-input args", async () => {
const server = await startMockServer();

View File

@@ -1482,6 +1482,16 @@ function buildAssistantText(
"- Re-run with a real model for qualitative coverage.",
].join("\n");
}
if (
toolOutput &&
(QA_TOOL_SEARCH_PROMPT_RE.test(allInputText) ||
QA_TOOL_SEARCH_FAILURE_PROMPT_RE.test(allInputText))
) {
const targetTool = extractToolSearchTarget(allInputText);
if (targetTool && toolOutput.includes(targetTool) && toolOutput.includes("FAKE_PLUGIN_OK")) {
return `FAKE_PLUGIN_OK ${targetTool}`;
}
}
if (toolOutput) {
const snippet = toolOutput.replace(/\s+/g, " ").trim().slice(0, 220);
return `Protocol note: I reviewed the requested material. Evidence snippet: ${snippet || "no content"}`;

View File

@@ -93,6 +93,28 @@ describe("matrix live qa runtime", () => {
}
});
it("summarizes relevant gateway stderr lines for Matrix QA failures", () => {
const summary = liveTesting.summarizeMatrixQaGatewayStderrLog(
[
"normal gateway progress",
"Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
"[agent/embedded] embedded run failover decision: stage=prompt decision=surface_error reason=auth",
"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header",
].join("\n"),
);
expect(summary).toContain("gateway stderr tail:");
expect(summary).toContain("Authorization: Bearer");
expect(summary).toContain("reason=auth");
expect(summary).toContain("unexpected status 401 Unauthorized");
expect(summary).not.toContain("normal gateway progress");
expect(summary).not.toContain("abcdefghijklmnopqrstuvwxyz");
});
it("skips empty gateway stderr summaries", () => {
expect(liveTesting.summarizeMatrixQaGatewayStderrLog("\n\n")).toBeUndefined();
});
it("normalizes the Matrix QA hard timeout env", () => {
const previous = process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS;
try {

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import {
parseStrictPositiveInteger,
resolveTimerTimeoutMs,
@@ -58,6 +59,9 @@ type MatrixQaGatewayChild = {
const DEFAULT_MATRIX_QA_RUN_TIMEOUT_MS = 30 * 60_000;
const DEFAULT_MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000;
const DEFAULT_MATRIX_QA_CANARY_TIMEOUT_MS = 45_000;
const MATRIX_QA_GATEWAY_STDERR_LOG = "gateway.stderr.log";
const MATRIX_QA_GATEWAY_DEBUG_MAX_LINES = 6;
const MATRIX_QA_GATEWAY_DEBUG_MAX_LINE_CHARS = 700;
type MatrixQaLiveLaneGatewayHarness = {
gateway: MatrixQaGatewayChild;
@@ -195,6 +199,44 @@ function writeMatrixQaProgress(message: string) {
process.stderr.write(`[matrix-qa] ${message}\n`);
}
function isMatrixQaGatewayDebugRelevantLine(line: string) {
return /\b(?:auth|authorization|unauthorized|forbidden|missing|error|fail(?:ed|ure)?|exception|provider|api[-_ ]?key|token|denied|rejected|timeout)\b/iu.test(
line,
);
}
function trimMatrixQaGatewayDebugLine(line: string) {
const redacted = redactSensitiveText(line.trim());
return redacted.length > MATRIX_QA_GATEWAY_DEBUG_MAX_LINE_CHARS
? `${redacted.slice(0, MATRIX_QA_GATEWAY_DEBUG_MAX_LINE_CHARS)}...`
: redacted;
}
function summarizeMatrixQaGatewayStderrLog(stderrText: string) {
const lines = stderrText.split(/\r?\n/u).map(trimMatrixQaGatewayDebugLine).filter(Boolean);
if (lines.length === 0) {
return undefined;
}
const relevantLines = lines.filter(isMatrixQaGatewayDebugRelevantLine);
const selectedLines = (relevantLines.length > 0 ? relevantLines : lines).slice(
-MATRIX_QA_GATEWAY_DEBUG_MAX_LINES,
);
return ["gateway stderr tail:", ...selectedLines.map((line) => `- ${line}`)].join("\n");
}
async function readMatrixQaGatewayDebugSummary(debugDirPath: string) {
const stderrText = await fs
.readFile(path.join(debugDirPath, MATRIX_QA_GATEWAY_STDERR_LOG), "utf8")
.catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw error;
});
return summarizeMatrixQaGatewayStderrLog(stderrText);
}
function parsePositiveMatrixQaEnvMs(name: string, fallback: number) {
const raw = process.env[name];
if (raw === undefined) {
@@ -1071,11 +1113,16 @@ export async function runMatrixQaLive(params: {
details: cleanupErrors.join("\n"),
});
}
const gatewayDebugSummary = preservedGatewayDebugDirPath
? await readMatrixQaGatewayDebugSummary(preservedGatewayDebugDirPath)
: undefined;
if (preservedGatewayDebugDirPath) {
checks.push({
name: "Matrix gateway debug logs",
status: "pass",
details: `preserved at: ${preservedGatewayDebugDirPath}`,
details: [`preserved at: ${preservedGatewayDebugDirPath}`, gatewayDebugSummary]
.filter(Boolean)
.join("\n"),
});
}
@@ -1189,6 +1236,7 @@ export async function runMatrixQaLive(params: {
details: [
...failedChecks.map((check) => `check ${check.name}: ${check.details ?? "failed"}`),
...failedScenarios.map((scenario) => `scenario ${scenario.id}: ${scenario.details}`),
...(gatewayDebugSummary ? [`gateway debug: ${gatewayDebugSummary}`] : []),
...cleanupErrors.map((error) => `cleanup: ${error}`),
],
artifacts: artifactPaths,
@@ -1231,6 +1279,7 @@ export const testing = {
resolveMatrixQaCanaryTimeoutMs,
resolveMatrixQaModels,
shouldWriteMatrixQaProgress,
summarizeMatrixQaGatewayStderrLog,
summarizeMatrixQaConfigSnapshot,
waitForMatrixChannelReady,
withMatrixQaRunDeadline,

View File

@@ -1,12 +1,8 @@
// Qa Matrix plugin module implements scenarios behavior.
import {
MATRIX_QA_BOT_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
MATRIX_QA_E2EE_ROOM_KEY,
MATRIX_QA_MEDIA_ROOM_KEY,
MATRIX_QA_PROFILE_NAMES,
MATRIX_QA_MEMBERSHIP_ROOM_KEY,
MATRIX_QA_SCENARIOS,
MATRIX_QA_SECONDARY_ROOM_KEY,
MATRIX_QA_STANDARD_SCENARIO_IDS,
@@ -20,10 +16,8 @@ import {
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMentionPrompt,
readMatrixQaSyncCursor,
runMatrixQaCanary,
runMatrixQaScenario,
writeMatrixQaSyncCursor,
type MatrixQaScenarioContext,
} from "./scenario-runtime.js";
import type { MatrixQaCanaryArtifact, MatrixQaScenarioArtifacts } from "./scenario-types.js";
@@ -41,13 +35,9 @@ export type { MatrixQaCanaryArtifact, MatrixQaScenarioArtifacts };
export type { MatrixQaScenarioContext };
export const testing = {
MATRIX_QA_BOT_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
MATRIX_QA_E2EE_ROOM_KEY,
MATRIX_QA_MEDIA_ROOM_KEY,
MATRIX_QA_MEMBERSHIP_ROOM_KEY,
MATRIX_QA_PROFILE_NAMES,
MATRIX_QA_SECONDARY_ROOM_KEY,
MATRIX_QA_STANDARD_SCENARIO_IDS,
buildMatrixQaE2eeScenarioRoomKey,
@@ -58,8 +48,6 @@ export const testing = {
findMatrixQaScenarios,
getMatrixQaProfileScenarioIds: matrixQaProfileTesting.getMatrixQaProfileScenarioIds,
normalizeMatrixQaProfile: matrixQaProfileTesting.normalizeMatrixQaProfile,
readMatrixQaSyncCursor,
resolveMatrixQaScenarioRoomId,
writeMatrixQaSyncCursor,
};
export { testing as __testing };

View File

@@ -0,0 +1,5 @@
# Raft (OpenClaw plugin)
Raft CLI wake bridge channel plugin for OpenClaw.
Docs: https://docs.openclaw.ai/channels/raft

View File

@@ -0,0 +1,2 @@
// Raft API module exposes the channel plugin contract.
export { raftPlugin } from "./src/channel.js";

13
extensions/raft/index.ts Normal file
View File

@@ -0,0 +1,13 @@
// Raft plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "raft",
name: "Raft",
description: "Raft CLI wake bridge channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "raftPlugin",
},
});

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