Compare commits

..

188 Commits

Author SHA1 Message Date
Peter Steinberger
896def06ad docs: document exec approval replay contracts 2026-06-02 07:30:47 -04:00
Peter Steinberger
36bf5505d0 docs: document gateway http pipeline contracts 2026-06-02 07:28:11 -04:00
Peter Steinberger
75a308fbb0 docs: document gateway auth branch contracts 2026-06-02 07:25:59 -04:00
Peter Steinberger
30348b6f2d docs: document gateway auth resolution contracts 2026-06-02 07:23:58 -04:00
Peter Steinberger
c23a828ab7 docs: document plugin http dispatch contracts 2026-06-02 07:23:58 -04:00
Peter Steinberger
3f9783be5a docs: document gateway server helper contracts 2026-06-02 07:23:58 -04:00
Peter Steinberger
e51c91d0cc docs: document websocket handshake pairing contracts 2026-06-02 07:23:58 -04:00
Peter Steinberger
9b9d8f9763 docs: document websocket connection lifecycle contracts 2026-06-02 07:23:58 -04:00
Peter Steinberger
356138633b docs: document websocket lifecycle helper contracts 2026-06-02 07:23:58 -04:00
Peter Steinberger
48c0f0772d docs: document websocket auth diagnostics contracts 2026-06-02 07:23:58 -04:00
Peter Steinberger
0de186815e docs: document websocket auth helper contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
7411d1cdbd docs: document gateway node pending work contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
f07f7bd193 docs: document gateway request context contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
e7e0205a3a docs: document gateway node event contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
7c01647400 docs: document gateway node registry contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
e9b28cea1b docs: document gateway node subscription registries 2026-06-02 07:23:57 -04:00
Peter Steinberger
a388be65eb docs: document gateway session state projection 2026-06-02 07:23:57 -04:00
Peter Steinberger
b2c3077a67 docs: document gateway session event runtime 2026-06-02 07:23:57 -04:00
Peter Steinberger
a1c55315bd docs: document session method contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
11c32af56f docs: document gateway transcript index contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
28eb7222e6 docs: document gateway transcript reader contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
219ce859e3 docs: document gateway session utility contracts 2026-06-02 07:23:57 -04:00
Peter Steinberger
65a8679bba docs: document gateway session runtime helpers 2026-06-02 07:23:57 -04:00
Peter Steinberger
d0a2f56c95 docs: document gateway session http helpers 2026-06-02 07:23:57 -04:00
Peter Steinberger
a4f7c06ec0 docs: document gateway session reset lifecycle 2026-06-02 07:23:57 -04:00
Peter Steinberger
4cafd4059e docs: document gateway session patch helper 2026-06-02 07:23:57 -04:00
Peter Steinberger
0720fcb63a docs: document gateway session resolver 2026-06-02 07:23:57 -04:00
Peter Steinberger
7b8fa1a253 docs: document gateway compaction checkpoint helpers 2026-06-02 07:23:57 -04:00
Peter Steinberger
e48800d4f4 docs: document gateway transcript archive helpers 2026-06-02 07:23:57 -04:00
Peter Steinberger
ea6224323a docs: document gateway session lifecycle state 2026-06-02 07:23:57 -04:00
Peter Steinberger
3f2ebc8c66 docs: document gateway session history state 2026-06-02 07:23:57 -04:00
Peter Steinberger
6928678dd6 docs: document gateway child session helpers 2026-06-02 07:23:57 -04:00
Peter Steinberger
e1be05bb9e docs: document gateway chat display projection 2026-06-02 07:23:57 -04:00
Peter Steinberger
c9704d6e14 docs: document gateway chat abort APIs 2026-06-02 07:23:57 -04:00
Peter Steinberger
2f85f2c629 docs: document gateway chat attachments 2026-06-02 07:23:57 -04:00
Peter Steinberger
deb2b6f2fd docs: document gateway chat sanitizers 2026-06-02 07:23:57 -04:00
Peter Steinberger
0d771204dc docs: document gateway client bootstrap 2026-06-02 07:23:57 -04:00
Peter Steinberger
7fec279d39 docs: document gateway token source conflict 2026-06-02 07:23:57 -04:00
Peter Steinberger
9e16ab92b2 docs: document gateway install auth policy 2026-06-02 07:23:57 -04:00
Peter Steinberger
3a05e5cc7f docs: document gateway auth mode policy 2026-06-02 07:23:57 -04:00
Peter Steinberger
457eb83f8f docs: document Control UI routing helpers 2026-06-02 07:23:57 -04:00
Peter Steinberger
08b5ac1d6d docs: document Control UI link resolution 2026-06-02 07:23:57 -04:00
Peter Steinberger
6d8df8718c docs: document Control UI HTTP helpers 2026-06-02 07:23:57 -04:00
Peter Steinberger
3399374f38 docs: document Control UI CSP helpers 2026-06-02 07:23:57 -04:00
Peter Steinberger
88184e2bd5 docs: document MCP loopback runtime 2026-06-02 07:23:57 -04:00
Peter Steinberger
ea2634c24e docs: document model pricing config 2026-06-02 07:23:57 -04:00
Peter Steinberger
baa1167fbe docs: document gateway reload settings 2026-06-02 07:23:57 -04:00
Peter Steinberger
8c939e677e docs: document gateway connection details 2026-06-02 07:23:57 -04:00
Peter Steinberger
736db3abae docs: document channel health policy 2026-06-02 07:23:56 -04:00
Peter Steinberger
8026fc9a61 docs: document gateway status patch helpers 2026-06-02 07:23:56 -04:00
Peter Steinberger
9b2ed68549 docs: document gateway model catalog cache 2026-06-02 07:23:56 -04:00
Peter Steinberger
016199ff3e docs: document gateway cron notifications 2026-06-02 07:23:56 -04:00
Peter Steinberger
dfdbfa150c docs: document channel plugin reload targets 2026-06-02 07:23:56 -04:00
Peter Steinberger
96f4a37666 docs: document gateway live state contracts 2026-06-02 07:23:56 -04:00
Peter Steinberger
ba85291383 docs: document gateway JSON endpoint helper 2026-06-02 07:23:56 -04:00
Peter Steinberger
e7518ed99a docs: document gateway HTTP auth helpers 2026-06-02 07:23:56 -04:00
Peter Steinberger
c41d189ef8 docs: document shared gateway HTTP helpers 2026-06-02 07:23:56 -04:00
Peter Steinberger
3a91ee83b3 docs: document gateway auth secret materialization 2026-06-02 07:23:56 -04:00
Peter Steinberger
bf32ca7fbf docs: document gateway auth token precedence 2026-06-02 07:23:56 -04:00
Peter Steinberger
76b1cff07a docs: document operator scope vocabulary 2026-06-02 07:23:56 -04:00
Peter Steinberger
6bdade887c docs: document gateway config bypass policy 2026-06-02 07:23:56 -04:00
Peter Steinberger
491027c9da docs: document hook agent allowlist policy 2026-06-02 07:23:56 -04:00
Peter Steinberger
bfcc40cbc7 docs: document approval runtime token contract 2026-06-02 07:23:56 -04:00
Peter Steinberger
bd327672a9 docs: document session store key contracts 2026-06-02 07:23:56 -04:00
Peter Steinberger
80ef5f5994 docs: document run session key lookup 2026-06-02 07:23:56 -04:00
Peter Steinberger
cfeb3cc2c5 docs: document transcript session key lookup 2026-06-02 07:23:56 -04:00
Peter Steinberger
1934921d87 docs: document gateway probe target resolution 2026-06-02 07:23:56 -04:00
Peter Steinberger
1245a34e4e docs: document gateway server utility contracts 2026-06-02 07:23:56 -04:00
Peter Steinberger
c8d96667c9 docs: document node pairing auto-approval policy 2026-06-02 07:23:56 -04:00
Peter Steinberger
329aff4dba docs: document mcp loopback protocol contracts 2026-06-02 07:23:56 -04:00
Peter Steinberger
421796e55d docs: document device auth metadata contracts 2026-06-02 07:23:56 -04:00
Peter Steinberger
b8308fc347 docs: document gateway client API contracts 2026-06-02 07:23:56 -04:00
Dirk
355cbc5071 fix(google): add missing gemini-3.1-flash-lite to google-vertex catalog (#89400)
* fix(google): add gemini-3.1-flash-lite to provider catalog

Adds the missing gemini-3.1-flash-lite model definition to the
GOOGLE_GEMINI_TEXT_MODELS array. This resolves the ProviderFailoverError
when configuring google-vertex/gemini-3.1-flash-lite.

Fixes #89390

* test(google): cover Gemini flash lite catalog row

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-02 07:21:50 -04:00
Peter Steinberger
b4dfa950b5 refactor: tighten agent harness surfaces
Refactor the agent harness surface after PR #88821 by moving compaction dispatch into its own module, splitting the harness type into explicit capability interfaces, and renaming the private agent-core class declaration to `CoreAgentHarness` while preserving the exported `AgentHarness` contract.

Verification:
- `node scripts/run-vitest.mjs src/agents/harness/selection.test.ts src/agents/command/cli-compaction.test.ts src/agents/embedded-agent-runner/compact.hooks.test.ts packages/agent-core/src/agent-loop.test.ts packages/agent-core/src/harness/messages.test.ts`
- `pnpm build`
- autoreview clean
- `pnpm check:changed` passed on Testbox `tbx_01kt407hq8sv1csm287pdj3fmp`
- PR CI merge state `CLEAN`
2026-06-02 07:20:43 -04:00
Mukunda Rao Katta
2d61521bd3 fix(update): pin post-core plugin compatibility to the downgraded core version (#87914) (#87952)
* fix(update): pin post-core plugin compatibility to the downgraded core version (#87914)

* fix(update): force plugin compatibility repair on rollback

* style(update): clarify downgrade compatibility note

* fix(plugins): resolve compatible prerelease plugin downgrades

* fix(plugins): honor host gates during npm downgrade repair

* fix(plugins): keep prerelease downgrade fallback on channel

---------

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-02 07:13:26 -04:00
Dallin Romney
30b9e123b8 fix: repeat doctor state migration repairs
Stabilize repeated `openclaw doctor --fix` state repairs for legacy plugin state and installed plugin index migrations.

- Import legacy-only plugin-state sidecar rows before deciding whether live conflicts require keeping the sidecar.
- Drop expired sidecar rows only when the sidecar can be archived, avoiding repeated false migration changes.
- Let richer current install records cover legacy records only when durable legacy fields are actually preserved, without erasing npm selector intent or malformed legacy metadata.

Proof:
- `node scripts/run-vitest.mjs src/commands/doctor-state-migrations.test.ts`
- `git diff --check origin/main...HEAD`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- PR CI clean for head `5f3a7e0749372a40cabd7a090cae155997481b71`

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-02 07:13:02 -04:00
Coy Geek
a14be505ff fix(qqbot): isolate credential backups by state root
QQBot credential backups now resolve under the active OpenClaw state directory instead of the old home-global QQBot data path. This keeps isolated gateway profiles from restoring each other's QQBot appId/clientSecret backups while preserving per-state-root recovery.

Proof: focused QQBot path/storage-laziness Vitest suite passed on Node 24.15.0, focused oxlint passed, source-runtime two-root backup proof passed, exact-head CI run 26814565282 passed, and ClawSweeper re-review run 26815054980 marked proof sufficient.

Closes #84313.

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
2026-06-02 07:11:01 -04:00
charles-openclaw
2c48dd2277 fix(sessions): preserve corrupt-header transcripts
Fixes #89037.

Co-authored-by: Charles <charles-openclaw@9bcfae.inboxapi.ai>
2026-06-02 07:02:09 -04:00
Hussein Nourelddine
4a285d529a feat(status): detect external plugin version drift
Surface active official external plugin version drift in gateway status diagnostics so users can see when a host/package update left npm or ClawHub plugins behind the running local gateway. The advisory uses the daemon service install records, compares against the running gateway version, gives detailed fix commands in deep status, and avoids local-state drift checks for remote gateway mode or explicit status probe URLs.

Co-authored-by: Hussein Nourelddine <hussein@gptc.com.kw>
2026-06-02 06:59:23 -04:00
Vincent Koc
07821e4bb8 refactor(gateway): share secret ref input resolution 2026-06-02 12:52:02 +02:00
Vincent Koc
4bae78858f refactor(gateway): share runtime service helpers 2026-06-02 12:52:02 +02:00
Andy Ye
1db2c2a3e0 Treat soft plugin repair warnings as nonfatal (#84431)
* Treat soft plugin repair warnings as nonfatal

* fix: scope plugin repair convergence failures

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-02 06:51:11 -04:00
NVIDIAN
eb417bc672 fix(messages): preserve inbound audio for message-tool TTS
Preserve inbound-audio context for message-tool TTS across embedded reply runs, CLI MCP loopback, and queued follow-up paths.

Thanks @ai-hpc.

Co-authored-by: ai-hpc <mail.speedy.hpc@hotmail.com>
2026-06-02 06:45:34 -04:00
Peter Steinberger
5d6216a7f1 fix: detect shrinkwrapped npm installs
Fixes status/update detection for npm-installed OpenClaw packages that ship npm-shrinkwrap while preserving pnpm and Bun install ownership.

Fixes #87732.
Supersedes #88283.

Proof: focused infra Vitest shard, autoreview clean, Crabbox install matrix, and PR CI all green.
2026-06-02 06:39:22 -04:00
Bek
bce3d5bf92 trace: Correlate channel diagnostics into one trace
Correlates channel receive, agent lifecycle, model attempt diagnostics, and outbound delivery diagnostics into one trace waterfall so channel message runs can be inspected end-to-end.

Maintainer follow-up removed the internal `AgentHarnessV2` adapter surface and kept the harness path canonical through `src/agents/harness/lifecycle.ts`.

Proof:
- PR checks passed on `04e9189c15480d53663d533a04c9883164b4dd54`.
- `node scripts/run-vitest.mjs src/agents/harness/lifecycle.test.ts src/agents/harness/selection.test.ts src/channels/turn/kernel.test.ts`
- `pnpm check:changed` Testbox `tbx_01kt3xtrm70qc7nb90cqv5rah1`

Thanks @bek91.

Co-authored-by: Bek <bek.akhmedov@gmail.com>
2026-06-02 06:38:00 -04:00
LiLan0125
ad9f7f9a59 fix(diagnostics): requeue stuck session lane after recovery
Reset the session command lane when stuck-session recovery aborts and drains a ghost embedded run but queued lane work remains. This preserves pending user messages by using the existing lane recovery pump instead of leaving them stranded after recovery reports success.

Adds focused regression coverage for the abort=true, drained=true, queuedCount=1 path.

Fixes #89208.
Supersedes #89293.
Thanks @LiLan0125.

Co-authored-by: 李兰 0668001394 <li.lan3@xydigit.com>
2026-06-02 06:36:19 -04:00
Gio Della-Libera
a25338f2b7 fix(discord): accumulate reasoning progress deltas
Fix Discord progress-mode reasoning streams so delta chunks accumulate before display formatting, preserving raw Thinking/Reasoning-prefixed content and balanced truncation.\n\nFixes #83983.\n\nThanks @giodl73-repo for the fix and live Discord proof.
2026-06-02 06:35:29 -04:00
Bek
6997453098 fix: guard in-band macOS launchd stop
Summary:
- guard macOS launchd stop/restart against in-band service relaunch loops
- centralize current-service detection for launchd stop and restart handoff
- preserve external launchd label stop overrides while fixing inherited XPC restart handoff

Verification:
- node scripts/run-vitest.mjs src/daemon/launchd.test.ts src/daemon/launchd-current-service.test.ts src/daemon/launchd-restart-handoff.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode local
- pnpm check:changed via Blacksmith Testbox through Crabbox: tbx_01kt3xkmfqhnzghfxdn62fa8qm

Closes #89174

Co-authored-by: Bek <bek.akhmedov@gmail.com>
2026-06-02 06:27:36 -04:00
Vincent Koc
c35fda3cfa refactor(gateway): derive websocket runtime params 2026-06-02 12:20:48 +02:00
Vincent Koc
8ea6b5d5b2 fix(scripts): clean package-boundary prep process groups 2026-06-02 12:15:53 +02:00
NVIDIAN
a02a7aaddb fix(codex): trace app-server thread lifecycle timing
Fixes #84640.
2026-06-02 06:11:58 -04:00
Pavan Kumar Gondhi
19fb9f1299 fix: redact trajectory exports consistently (#89354)
* fix trajectory export redaction

* fix trajectory export top-level redaction

* fix trajectory export key redaction

* fix trajectory export structural key redaction
2026-06-02 15:41:44 +05:30
兰之
2664f59519 fix(cron): reject blank delivery targets
Reject whitespace-only cron delivery target strings before cron input normalization can trim and drop them, so bad delivery targets return INVALID_REQUEST instead of behaving as omitted fields.

Keep explicit null update clears for delivery, failure destination, and completion destination fields.

Co-authored-by: gaozixiang1 <gaozixiang1@xiaomi.com>
Co-authored-by: Lanzhi <lizhan3@xiaomi.com>
2026-06-02 06:10:19 -04:00
兰之
1cca70940c fix: hide sessions_spawn timeout overrides
Remove model-facing per-call timeout overrides from sessions_spawn while keeping operator-controlled timeout behavior through agents.defaults.subagents.runTimeoutSeconds.

Reject stale camelCase and snake_case timeout arguments, update ACP/native timeout propagation, refresh docs and prompt snapshots, and cap ACP runtime option timeouts to the ACP control-plane maximum without shortening gateway dispatch or registry tracking.

Proof:
- node --import tsx - runtime probe against src/agents/tools/sessions-spawn-tool.ts
- node scripts/run-vitest.mjs src/agents/tools/sessions-spawn-tool.test.ts src/agents/acp-spawn.test.ts src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts
- pnpm docs:list
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub checks: 132 pass, 30 skipped

Co-authored-by: Lanzhi <lizhan3@xiaomi.com>
Co-authored-by: chenhaoqiang <chenhaoqiang@xiaomi.com>
2026-06-02 06:09:02 -04:00
兰之
43d0aaec3d fix(agents): honor provider idle timeout for unlimited runs
Honor explicit provider/model request timeoutSeconds when the agent run timeout is the no-timeout sentinel, and keep explicit run timeout overrides from being capped by agent defaults.

Verification:
- pnpm test src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts -- --reporter=verbose
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- CI run 26812803642 passed on the rebased PR head
- Real behavior proof run 26812917801 passed after maintainer proof override

Co-authored-by: zhongqiongbo1 <zhongqiongbo1@xiaomi.com>
Co-authored-by: Lanzhi <lizhan3@xiaomi.com>
2026-06-02 06:08:56 -04:00
Vincent Koc
5487855815 refactor(gateway): share talk relay session lifecycle 2026-06-02 11:57:08 +02:00
Vincent Koc
45f7aec156 refactor(gateway): share transcript path comparison 2026-06-02 11:57:08 +02:00
Vincent Koc
286c8e3632 fix(build): parallelize startup metadata rendering 2026-06-02 11:50:49 +02:00
Vincent Koc
e24582d53c fix(crabbox): preflight sparse sync disk space 2026-06-02 11:42:14 +02:00
Vincent Koc
3e9b197bd0 test(gateway): share node invoke acknowledgement 2026-06-02 11:24:01 +02:00
Vincent Koc
601ab84f35 test(gateway): share configured global session stores 2026-06-02 11:13:36 +02:00
clawsweeper[bot]
abc3fa0396 fix(memory-core): keep startup cron retries quiet (#89075)
Summary:
- The branch adds a memory-core `startup_retry` reconciliation mode and regression tests for quiet startup retries, retry-window exhaustion, and live-config retry semantics.
- PR surface: Source +9, Tests +114. Total +123 across 2 files.
- Reproducibility: yes. from source: current main routes the first startup retry through runtime reconciliatio ... st expects the warn-level `cron service unavailable` log. I did not execute tests in this read-only review.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: fix(memory-core): keep startup cron retries quiet

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

Prepared head SHA: 7220f940d0
Review: https://github.com/openclaw/openclaw/pull/89075#issuecomment-4592446250

Co-authored-by: bennewell35 <newelljben@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-02 09:09:52 +00:00
Ayaan Zaidi
db576c4a2d refactor(agents): trim native compaction ownership follow-up 2026-06-02 14:39:35 +05:30
Cameron Beeley
5e52a9b513 docs(cli-backends): document ownsNativeCompaction opt-out contract 2026-06-02 14:39:35 +05:30
Cameron Beeley
3d7523b618 feat(agents): generalized native compaction ownership for CLI backends
Add `ownsNativeCompaction` capability to CliBackendPlugin so backends
that manage their own transcript compaction (e.g. Claude Code) can
declare it once and OpenClaw defers instead of fighting or failing.

Today only Codex declares compaction ownership (via the embedded runner
path + agentHarnessId). Claude-cli never reaches that path because it
runs as a CLI subprocess with no harness id set, so the safeguard
summarizer fires and hard-fails the turn.

This PR:
- Adds `ownsNativeCompaction?: boolean` to the backend plugin type
- Propagates it through all 4 backend resolution paths
- In `runCliTurnCompactionLifecycle`, when a backend declares ownership
  but has no harness endpoint, returns a no-op instead of falling
  through to the safeguard
- Sets the flag on claude-cli (first adopter)

Codex's existing native-harness path is unchanged: when
`isNativeHarnessCompactionSession` matches, the harness compaction
endpoint is still called as before.

Generalizes the partial fix in #87785 (codex-scoped) to a capability
any backend can opt into.
2026-06-02 14:39:35 +05:30
Vincent Koc
afbf895af0 test(gateway): share runtime state fixture 2026-06-02 10:55:01 +02:00
Vincent Koc
af9bad9fe7 fix(gateway): avoid sync Control UI asset reads 2026-06-02 10:53:31 +02:00
Vincent Koc
3995d57797 refactor(gateway): share fast-path secrets prepare args 2026-06-02 10:44:55 +02:00
Vincent Koc
dcf21ac3ad fix(e2e): isolate release scenario mock state 2026-06-02 10:42:22 +02:00
Vincent Koc
e128efa13a fix(e2e): isolate OpenAI web search smoke logs 2026-06-02 10:34:44 +02:00
Vincent Koc
7f1c991e44 fix(scripts): forward wrapper hangup signals 2026-06-02 10:23:18 +02:00
Vincent Koc
a682e64813 refactor(gateway): share plugin install diff walk 2026-06-02 10:15:06 +02:00
Ayaan Zaidi
e31f351923 fix(android): classify updated system apps 2026-06-02 13:44:45 +05:30
Tosko4
5f505236a6 docs(android): document device apps command 2026-06-02 13:44:45 +05:30
Tosko4
3d1ec37129 feat(android): add installed apps node command 2026-06-02 13:44:45 +05:30
Vincent Koc
6c8e065e3b test(gateway): share scheduled service activation setup 2026-06-02 09:59:09 +02:00
Vincent Koc
cd3887c28a fix(scripts): cancel timed-out response bodies 2026-06-02 09:49:02 +02:00
Vincent Koc
92d363773e test(gateway): reuse record assertions in artifact tests 2026-06-02 09:39:01 +02:00
Vincent Koc
4d3411349b test(gateway): reuse deferred helper in lane tests 2026-06-02 09:28:53 +02:00
Vincent Koc
5912b9e738 fix(gateway): return mcp oversized body errors 2026-06-02 09:25:38 +02:00
Vincent Koc
64d01ff8a8 test(gateway): share deferred helper 2026-06-02 09:12:31 +02:00
Vincent Koc
06f973dd4f test(gateway): share record assertion helpers 2026-06-02 09:02:54 +02:00
Sliverp
0552ec899f fix(qqbot): allow RFC2544 benchmark range for token fetch (#88984) (#89015)
* fix(qqbot): allow RFC2544 benchmark range for token fetch (#88984)

QQ Bot `bots.qq.com` token-fetch path was failing for users whose DNS resolver maps the hostname into the RFC 2544 benchmark range `198.18.0.0/15` (commonly seen with fake-IP proxy stacks: sing-box, Clash, Surge, WSL2 DNS). The default SSRF guard treats that range as private and blocks the request, surfacing as "Network error getting access_token: Blocked: resolves to private/internal/special-use IP address".

Pass a host-scoped `SsrFPolicy` (`allowRfc2544BenchmarkRange: true`) to the single hard-coded `TOKEN_URL` request, mirroring the existing `QQBOT_MEDIA_SSRF_POLICY` pattern used by the media path. Because `TOKEN_URL` is a const and not user-controlled, the relaxation cannot widen attack surface to other hosts.

Adds a regression test asserting `policy: { allowRfc2544BenchmarkRange: true }` is forwarded into `fetchWithSsrFGuard`, and updates the existing equality assertion accordingly.

Fixes #88984

* fix(qqbot): scope token ssrf policy
2026-06-02 15:00:39 +08:00
Vincent Koc
f37ce4ed9b fix(gateway): report pending drain pruning revisions 2026-06-02 08:55:47 +02:00
Dallin Romney
20e0d068a7 fix: bundle private llm core declarations (#89336) 2026-06-01 23:51:38 -07:00
Vincent Koc
c0400397df test(gateway): share agent image request helpers 2026-06-02 08:38:48 +02:00
Peter Steinberger
732d6972d7 fix: repair model provider edge cases
Repairs a batch of narrow model/provider edge cases:

- honor OpenAI and Anthropic base URL environment overrides when provider config does not set an explicit base URL
- preserve OpenRouter Anthropic cache retention while stripping unsupported transport options
- allow apply_patch for non-OpenAI providers when the tool config otherwise permits it
- prune stale same-provider model selections from configure/model picker state
- expose GitHub Copilot bundled thinking policy metadata to offline/provider-policy lookups
- repair additive SQLite shared-state upgrades for existing databases
- keep same-size rotated log readers from reusing stale content in CI tooling

Proof:

- GitHub PR checks green on exact head 46514909b0
- Crabbox delegated Blacksmith Testbox tbx_01kt3em5r9vd7g0bnykrff6jdk exited 0
- Focused local Vitest/oxlint/format proof recorded in PR body and land-ready comment

Fixes #80347.
Fixes #88357.
Fixes #45269.
Supersedes #74427, #74432, #79370, #79894, #80366, and #88359.
2026-06-02 02:35:12 -04:00
Vincent Koc
438eb26d39 fix(ci): keep crabbox sync checkouts alive 2026-06-02 08:29:50 +02:00
Vincent Koc
fd1e314e59 test(gateway): share boot run helpers 2026-06-02 08:23:03 +02:00
Onur Solmaz
a4b4fed412 fix(memory): validate memory index identity
* docs: add memory index identity plan

* fix(memory): validate memory index identity

* fix(memory): align status index identity with vector probe

* fix(memory): fail closed on stale fts-only search

* fix(memory): clear sessions-only identity reindex dirty state

* fix(memory): gate targeted session sync by index identity

* fix(memory): clear resolved index identity dirtiness

* fix(memory): block search on missing index identity

* fix(memory): preserve dirty events during identity reindex

* fix(memory): resolve provider aliases for index identity

* fix(memory): report missing identity states accurately

* fix(memory): mark missing session index identity dirty

* test(memory): expose provider alias resolver in mocks

* chore(memory): remove scratch implementation plan

* fix(memory): avoid automatic full reindex on provider cutover

* docs(memory): plan no-schema cutover repair

* fix(memory): pause vector search on index identity mismatch

* fix(memory): freeze dirty identity sync writes

* fix(memory): skip paused-index search retry

* test(memory): keep retry tests on same provider identity

* fix(memory): surface paused index recall

* chore(memory): remove scratch plan from pr

* fix(memory): preserve paused session dirtiness

* fix(memory): make paused recall warning explicit

* docs(memory): document explicit index repair
2026-06-02 14:22:25 +08:00
Abner Shang
5be282e459 fix(backup): accept root-relative hardlink targets (#89328) 2026-06-01 23:09:21 -07:00
Vincent Koc
4df832412e fix(ci): normalize macos crabbox locale 2026-06-02 08:06:54 +02:00
Vincent Koc
3901f48b0e test(gateway): share channel health fixtures 2026-06-02 07:57:14 +02:00
Vincent Koc
85d2dd8ed2 refactor(gateway): share session history snapshot build 2026-06-02 07:46:38 +02:00
Vincent Koc
46bd5ebd11 refactor(gateway): share realtime tool result broadcast 2026-06-02 07:37:52 +02:00
Vincent Koc
5c93de3e7f refactor(gateway): share hook dispatch session policy 2026-06-02 07:28:31 +02:00
Vincent Koc
b579c0a65b fix(llm): normalize streaming json args 2026-06-02 07:24:19 +02:00
Vincent Koc
94adfc8d10 test(gateway): share node catalog fixtures 2026-06-02 07:13:17 +02:00
Vincent Koc
6883351085 fix(e2e): detect same-size log rotation 2026-06-02 07:11:57 +02:00
Vincent Koc
93fd17447a fix(talk): preserve null lifecycle payloads 2026-06-02 07:05:05 +02:00
Vincent Koc
ebf20241bd test(gateway): share deferred test helper 2026-06-02 06:53:31 +02:00
Vincent Koc
16808524cb refactor: share mcp loopback scope params 2026-06-02 06:44:47 +02:00
Vincent Koc
58de2b689f fix(nodes): preserve falsy event payloads 2026-06-02 06:39:00 +02:00
Vincent Koc
55467f0b94 refactor: share config write response flow 2026-06-02 06:32:06 +02:00
Vincent Koc
6ba25c10dc fix(build): cap tsdown heap on native Windows 2026-06-02 06:25:27 +02:00
Vincent Koc
3419cf5a0d fix(codex): preserve null sandbox rpc results 2026-06-02 06:23:53 +02:00
Peter Steinberger
265926aa47 fix: honor channel model overrides in agent ingress 2026-06-02 00:20:21 -04:00
clawsweeper[bot]
63ed9adfe9 fix(auto-reply): guard missing dispatcher getFailedCounts without weakening the SDK type (#89318)
Summary:
- Adds defensive failed-count reads in auto-reply/ACP accounting and Feishu fallback paths, plus a focused regression test, while keeping `ReplyDispatcher.getFailedCounts` required.
- PR surface: Source +24, Tests +35. Total +59 across 5 files.
- Reproducibility: yes. from source inspection. Current main calls `dispatcher.getFailedCounts().final` and si ... issing that method follows a clear TypeError path; the source PR also supplied terminal before/after proof.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(auto-reply): guard missing dispatcher getFailedCounts without wea…

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

Prepared head SHA: 0bdfb4adeb
Review: https://github.com/openclaw/openclaw/pull/89318#issuecomment-4598624344

Co-authored-by: Alix-007 <li.long15@xydigit.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-02 04:16:58 +00:00
Vincent Koc
e6b5083660 refactor: share gateway misc test helpers 2026-06-02 06:15:56 +02:00
WJzz1
6349af6502 docs: add ClawHub CLI page (#89297)
Summary:
- Adds `docs/clawhub/cli.md` documenting OpenClaw skill/plugin ClawHub commands plus standalone ClawHub publish, sync, and transfer workflows.
- PR surface: Docs +82. Total +82 across 1 file.
- Reproducibility: not applicable. this is a docs-only missing-route repair rather than a runtime bug. Source  ... rrent main lacks `docs/clawhub/cli.md` while navigation and existing docs already reference `/clawhub/cli`.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs: add ClawHub CLI page
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8929…

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

Prepared head SHA: 11e071c344
Review: https://github.com/openclaw/openclaw/pull/89297#issuecomment-4598332147

Co-authored-by: Wang-Yeah623 <205193123+Wang-Yeah623@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-02 04:13:50 +00:00
Vincent Koc
ffbd02fe8e fix(agents): preserve null node payloads 2026-06-02 06:03:06 +02:00
Vincent Koc
75bc80bb42 refactor: share exec approval iOS push fixtures 2026-06-02 06:02:15 +02:00
Vincent Koc
1e7a0d8987 refactor: share startup auth test helpers 2026-06-02 05:47:24 +02:00
Vincent Koc
39f319c7a4 fix(e2e): preserve gateway null payloads 2026-06-02 05:44:37 +02:00
Vincent Koc
7c4fb1bd2c refactor: share session search test helpers 2026-06-02 05:42:38 +02:00
Vincent Koc
7d5d62511f fix(e2e): preserve null rpc results 2026-06-02 05:33:07 +02:00
Vincent Koc
cc6a6f5682 refactor: share readiness test fixtures 2026-06-02 05:32:25 +02:00
Vincent Koc
7a8d307bdc refactor: share node invoke approval test helpers 2026-06-02 05:24:23 +02:00
Peter Steinberger
b7d363cadf fix(agents): bypass stale auth for plugin harnesses
Explicit non-Codex plugin harness runtimes now bypass stale OpenClaw provider auth cooldowns before harness startup, while Codex/OpenClaw and missing-harness gates remain fail-closed. Fixes #85105.
2026-06-01 23:22:54 -04:00
Vincent Koc
68b4dd1816 fix(crabbox): serialize macos node bootstrap 2026-06-02 05:21:16 +02:00
Vincent Koc
0e16e72091 refactor: share session reset hook test helpers 2026-06-02 05:16:03 +02:00
Peter Steinberger
9ead0ae921 fix: repair live model inference edge cases
Fix live model inference edge cases across provider streaming, model switching, outbound delivery, and gateway tool resolution.

Includes live/provider issue fixes and leaves #89100 explicitly partial for the remaining FM-2 group routing case.
2026-06-01 23:03:27 -04:00
Vincent Koc
3128ec9858 refactor: share gateway probe test helpers 2026-06-02 04:59:36 +02:00
Vincent Koc
1ec291c682 fix(ios): require explicit gateway log target 2026-06-02 04:52:50 +02:00
Vincent Koc
9d9a6140a3 refactor: share sessions list changed test helpers 2026-06-02 04:48:54 +02:00
Vincent Koc
674bd6fc93 fix(mac): isolate build run logs 2026-06-02 04:47:00 +02:00
Peter Steinberger
b2a55a282a fix(update): do not fail core update on plugin repair fetch 2026-06-02 03:42:54 +01:00
Vincent Koc
3cf4c1ad69 refactor: share connect policy test helpers 2026-06-02 04:38:59 +02:00
Vincent Koc
fa9ce6ea0e fix(mac): isolate dmg resize limits 2026-06-02 04:32:38 +02:00
Vincent Koc
0f1f1a1fd7 refactor: share startup config recovery test helpers 2026-06-02 04:29:40 +02:00
Vincent Koc
d944aaa9ec fix(test): reject retired live shard 2026-06-02 04:20:53 +02:00
Vincent Koc
baade28397 refactor: share subagent delivery context test helpers 2026-06-02 04:20:09 +02:00
Vincent Koc
883c0f1254 fix(mac): scope restart log by worktree 2026-06-02 04:11:23 +02:00
Vincent Koc
793ab78ebb refactor: share cron validation test helpers 2026-06-02 04:08:21 +02:00
Peter Steinberger
57ea5aff81 test(release): expect cheap docker preflight 2026-06-02 03:03:48 +01:00
Vincent Koc
f1d65b3cd6 fix(e2e): isolate trash shim bin dir 2026-06-02 04:01:47 +02:00
Vincent Koc
e6b951a6a6 refactor: share operator approval client test setup 2026-06-02 03:58:27 +02:00
Vincent Koc
55e9194a4c perf(scripts): avoid duplicate build cache input hashing 2026-06-02 03:50:19 +02:00
Vincent Koc
8929838159 refactor: share gateway credentials test fixtures 2026-06-02 03:49:48 +02:00
Peter Steinberger
a355c8897d ci(release): keep docker preflight cheap 2026-06-02 02:48:41 +01:00
Vincent Koc
b06dc17537 refactor: share gateway e2e test setup 2026-06-02 03:40:29 +02:00
Vincent Koc
7967a3582c fix(e2e): isolate onboard gateway logs 2026-06-02 03:39:10 +02:00
Vincent Koc
2e6016fdec fix(ci): keep crabbox pnpm hydrate off tmpfs 2026-06-02 03:38:51 +02:00
Peter Steinberger
8a1a8ea8a3 ci(release): wait out live provider rate limits 2026-06-02 02:38:22 +01:00
Vincent Koc
4608f7dcf9 refactor: share probe auth test fixtures 2026-06-02 03:29:33 +02:00
Vincent Koc
49ac93bda6 refactor: share talk session response helpers 2026-06-02 03:20:00 +02:00
Peter Steinberger
f6653b9b35 fix(ci): retry live Docker image pulls 2026-06-02 02:08:26 +01:00
Vincent Koc
2f92fddef0 refactor: share node invoke wake test helpers 2026-06-02 03:02:03 +02:00
1277 changed files with 20210 additions and 16123 deletions

View File

@@ -32,11 +32,11 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_CHILD_CONCURRENCY: "1"
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
PNPM_CONFIG_MODULES_DIR: "/var/tmp/openclaw-pnpm-node-modules"
PNPM_CONFIG_NETWORK_CONCURRENCY: "1"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
PNPM_CONFIG_STORE_DIR: "/var/tmp/openclaw-pnpm-store"
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/var/tmp/openclaw-pnpm-virtual-store"
jobs:
hydrate:

View File

@@ -229,7 +229,7 @@ jobs:
needs: [resolve_target]
if: inputs.rerun_group == 'all'
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: 20
permissions:
contents: read
steps:
@@ -245,54 +245,11 @@ jobs:
DOCKER_BUILDKIT: "1"
run: |
set -euo pipefail
timeout --kill-after=30s 35m docker build \
timeout --kill-after=30s 15m docker build \
--target runtime-assets \
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
.
- name: Build and smoke test final Docker runtime image
env:
DOCKER_BUILDKIT: "1"
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
run: |
set -euo pipefail
image_ref="openclaw-release-runtime-smoke:${TARGET_SHA}"
timeout --kill-after=30s 35m docker build \
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
-t "${image_ref}" \
.
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
set -eu
test -f /app/src/agents/templates/HEARTBEAT.md
temp_root="$(mktemp -d)"
trap "rm -rf \"${temp_root}\"" EXIT
mkdir -p "${temp_root}/home" "${temp_root}/cwd"
cd "${temp_root}/cwd"
set +e
HOME="${temp_root}/home" \
USERPROFILE="${temp_root}/home" \
OPENCLAW_HOME="${temp_root}/home" \
OPENCLAW_NO_ONBOARD=1 \
OPENCLAW_SUPPRESS_NOTES=1 \
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \
AWS_EC2_METADATA_DISABLED=true \
AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \
AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \
node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \
>"${temp_root}/out.log" 2>&1
status="$?"
set -e
if grep -F "Missing workspace template:" "${temp_root}/out.log"; then
cat "${temp_root}/out.log"
exit 1
fi
test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md"
if [ "${status}" -ne 0 ]; then
cat "${temp_root}/out.log"
fi
'
normal_ci:
name: Run normal full CI
needs: [resolve_target, docker_runtime_assets_preflight]

View File

@@ -45,6 +45,17 @@ Docs: https://docs.openclaw.ai
### Fixes
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
- Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.
- Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.

View File

@@ -218,6 +218,7 @@ Current OpenClaw Android implication:
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
- The repo now ships this split as Android product flavors:
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
- Installed-app listing is user controlled. `device.apps` is advertised only after the user enables **Settings > Phone Capabilities > Installed Apps**. The command defaults to launcher-visible apps and does not require `QUERY_ALL_PACKAGES`.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
Policy links:

View File

@@ -148,6 +148,7 @@ class MainViewModel(
val gatewayBootstrapToken: StateFlow<String> = prefs.gatewayBootstrapToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
@@ -299,6 +300,10 @@ class MainViewModel(
prefs.setCanvasDebugStatusEnabled(value)
}
fun setInstalledAppsSharingEnabled(value: Boolean) {
ensureRuntime().setInstalledAppsSharingEnabled(value)
}
fun setNotificationForwardingEnabled(value: Boolean) {
ensureRuntime().setNotificationForwardingEnabled(value)
}

View File

@@ -207,6 +207,7 @@ class NodeRuntime(
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
hasRecordAudioPermission = { hasRecordAudioPermission() },
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
manualTls = { manualTls.value },
)
@@ -245,6 +246,7 @@ class NodeRuntime(
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
debugBuild = { BuildConfig.DEBUG },
onCanvasA2uiPush = {
_canvasA2uiHydrated.value = true
@@ -866,6 +868,7 @@ class NodeRuntime(
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
prefs.notificationForwardingMode
@@ -1077,6 +1080,12 @@ class NodeRuntime(
prefs.setCanvasDebugStatusEnabled(value)
}
fun setInstalledAppsSharingEnabled(value: Boolean) {
if (prefs.installedAppsSharingEnabled.value == value) return
prefs.setInstalledAppsSharingEnabled(value)
refreshNodeSurfaceAfterSharingChange()
}
fun setNotificationForwardingEnabled(value: Boolean) {
prefs.setNotificationForwardingEnabled(value)
}
@@ -1414,6 +1423,11 @@ class NodeRuntime(
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
}
private fun refreshNodeSurfaceAfterSharingChange() {
val endpoint = connectedEndpoint ?: return
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
}
private fun connectWithAuth(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,

View File

@@ -40,11 +40,13 @@ class SecurePrefs(
private const val notificationsForwardingMaxEventsPerMinuteKey =
"notifications.forwarding.maxEventsPerMinute"
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
private const val voiceMicEnabledKey = "voice.micEnabled"
}
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
@@ -114,6 +116,10 @@ class SecurePrefs(
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _installedAppsSharingEnabled =
MutableStateFlow(plainPrefs.getBoolean(installedAppsSharingEnabledKey, false))
val installedAppsSharingEnabled: StateFlow<Boolean> = _installedAppsSharingEnabled
private val _notificationForwardingEnabled =
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
@@ -252,6 +258,11 @@ class SecurePrefs(
_canvasDebugStatusEnabled.value = value
}
fun setInstalledAppsSharingEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(installedAppsSharingEnabledKey, value) }
_installedAppsSharingEnabled.value = value
}
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)

View File

@@ -28,6 +28,7 @@ class ConnectionManager(
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val installedAppsSharingEnabled: () -> Boolean,
private val manualTls: () -> Boolean,
) {
companion object {
@@ -115,6 +116,7 @@ class ConnectionManager(
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),
installedAppsSharingEnabled = installedAppsSharingEnabled(),
debugBuild = BuildConfig.DEBUG,
)

View File

@@ -8,6 +8,7 @@ import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
@@ -24,16 +25,121 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.Locale
private const val DEFAULT_DEVICE_APPS_LIMIT = 100
private const val MAX_DEVICE_APPS_LIMIT = 200
private const val DEVICE_APPS_SYSTEM_FLAGS =
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean =
(appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
internal data class DeviceAppEntry(
val label: String,
val packageName: String,
val system: Boolean,
val enabled: Boolean,
val launchable: Boolean,
)
internal interface DeviceAppSource {
fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry>
}
private class AndroidDeviceAppSource(
private val appContext: Context,
) : DeviceAppSource {
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
val packageManager = appContext.packageManager
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
val launchablePackages =
packageManager
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.asSequence()
.mapNotNull {
it.activityInfo
?.packageName
?.trim()
?.takeIf(String::isNotEmpty)
}.toSet()
val appInfos =
if (includeNonLaunchable) {
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
} else {
launchablePackages.mapNotNull { packageName ->
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
}
}
return appInfos
.asSequence()
.mapNotNull { appInfo ->
appInfo.packageName
?.trim()
?.takeIf(String::isNotEmpty)
?.let { packageName ->
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
DeviceAppEntry(
label = label.ifEmpty { packageName },
packageName = packageName,
system = isSystemDeviceApp(appInfo),
enabled = appInfo.enabled,
launchable = packageName in launchablePackages,
)
}
}.distinctBy { it.packageName }
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
}
private data class DeviceAppsRequest(
val includeSystem: Boolean,
val includeDisabled: Boolean,
val includeNonLaunchable: Boolean,
val query: String?,
val limit: Int,
)
/**
* Gateway device command adapter for Android status, info, permission, and health snapshots.
*/
class DeviceHandler(
class DeviceHandler private constructor(
private val appContext: Context,
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
private val appSource: DeviceAppSource = AndroidDeviceAppSource(appContext),
) {
constructor(
appContext: Context,
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
) : this(
appContext = appContext,
smsEnabled = smsEnabled,
callLogEnabled = callLogEnabled,
photosEnabled = photosEnabled,
appSource = AndroidDeviceAppSource(appContext),
)
companion object {
internal fun forTesting(
appContext: Context,
appSource: DeviceAppSource,
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
): DeviceHandler =
DeviceHandler(
appContext = appContext,
smsEnabled = smsEnabled,
callLogEnabled = callLogEnabled,
photosEnabled = photosEnabled,
appSource = appSource,
)
/**
* SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align.
*/
@@ -74,6 +180,48 @@ class DeviceHandler(
/** Returns coarse device health for memory, power, thermal, battery, and security patch state. */
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
fun handleDeviceApps(paramsJson: String?): GatewaySession.InvokeResult {
val request = parseDeviceAppsRequest(paramsJson)
val matchingApps =
appSource
.listApps(includeNonLaunchable = request.includeNonLaunchable)
.asSequence()
.filter { request.includeSystem || !it.system }
.filter { request.includeDisabled || it.enabled }
.filter { app ->
val query = request.query ?: return@filter true
app.label.contains(query, ignoreCase = true) || app.packageName.contains(query, ignoreCase = true)
}.toList()
val limitedApps = matchingApps.take(request.limit)
return GatewaySession.InvokeResult.ok(
buildJsonObject {
put("count", JsonPrimitive(limitedApps.size))
put("totalMatched", JsonPrimitive(matchingApps.size))
put("truncated", JsonPrimitive(matchingApps.size > limitedApps.size))
put("visibility", JsonPrimitive(if (request.includeNonLaunchable) "android-visible" else "launcher"))
put("includeSystem", JsonPrimitive(request.includeSystem))
put("includeDisabled", JsonPrimitive(request.includeDisabled))
put(
"apps",
buildJsonArray {
for (app in limitedApps) {
add(
buildJsonObject {
put("label", JsonPrimitive(app.label))
put("packageName", JsonPrimitive(app.packageName))
put("system", JsonPrimitive(app.system))
put("enabled", JsonPrimitive(app.enabled))
put("launchable", JsonPrimitive(app.launchable))
},
)
}
},
)
}.toString(),
)
}
private fun statusPayloadJson(): String {
val battery = readBatterySnapshot()
val powerManager = appContext.getSystemService(PowerManager::class.java)
@@ -365,6 +513,24 @@ class DeviceHandler(
}.toString()
}
private fun parseDeviceAppsRequest(paramsJson: String?): DeviceAppsRequest {
val params = parseJsonParamsObject(paramsJson)
val includeSystem = parseJsonBooleanFlag(params, "includeSystem") ?: false
val includeDisabled = parseJsonBooleanFlag(params, "includeDisabled") ?: false
val includeNonLaunchable = parseJsonBooleanFlag(params, "includeNonLaunchable") ?: false
val query = parseJsonString(params, "query")?.trim()?.takeIf { it.isNotEmpty() }
val limit =
(parseJsonInt(params, "limit") ?: DEFAULT_DEVICE_APPS_LIMIT)
.coerceIn(1, MAX_DEVICE_APPS_LIMIT)
return DeviceAppsRequest(
includeSystem = includeSystem,
includeDisabled = includeDisabled,
includeNonLaunchable = includeNonLaunchable,
query = query,
limit = limit,
)
}
private fun readBatterySnapshot(): BatterySnapshot {
// ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot.
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))

View File

@@ -28,6 +28,7 @@ data class NodeRuntimeFlags(
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
val installedAppsSharingEnabled: Boolean,
val debugBuild: Boolean,
)
@@ -43,6 +44,7 @@ enum class InvokeCommandAvailability {
PhotosAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
InstalledAppsSharingEnabled,
DebugBuild,
}
@@ -193,6 +195,10 @@ object InvokeCommandRegistry {
InvokeCommandSpec(
name = OpenClawDeviceCommand.Health.rawValue,
),
InvokeCommandSpec(
name = OpenClawDeviceCommand.Apps.rawValue,
availability = InvokeCommandAvailability.InstalledAppsSharingEnabled,
),
InvokeCommandSpec(
name = OpenClawNotificationsCommand.List.rawValue,
),
@@ -281,6 +287,7 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.InstalledAppsSharingEnabled -> flags.installedAppsSharingEnabled
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
}
}.map { it.name }

View File

@@ -85,6 +85,7 @@ class InvokeDispatcher(
private val smsTelephonyAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val installedAppsSharingEnabled: () -> Boolean,
private val debugBuild: () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
private val onCanvasA2uiReset: () -> Unit,
@@ -193,6 +194,7 @@ class InvokeDispatcher(
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
OpenClawDeviceCommand.Apps.rawValue -> deviceHandler.handleDeviceApps(paramsJson)
// Notifications command
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
@@ -348,6 +350,15 @@ class InvokeDispatcher(
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
)
}
InvokeCommandAvailability.InstalledAppsSharingEnabled ->
if (installedAppsSharingEnabled()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "INSTALLED_APPS_SHARING_DISABLED",
message = "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
)
}
InvokeCommandAvailability.DebugBuild ->
if (debugBuild()) {
null

View File

@@ -112,6 +112,7 @@ enum class OpenClawDeviceCommand(
Info("device.info"),
Permissions("device.permissions"),
Health("device.health"),
Apps("device.apps"),
;
companion object {

View File

@@ -714,6 +714,7 @@ private fun PhoneCapabilitiesScreen(
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val installedAppsSharingEnabled by viewModel.installedAppsSharingEnabled.collectAsState()
val cameraPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
viewModel.setCameraEnabled(granted)
@@ -768,6 +769,13 @@ private fun PhoneCapabilitiesScreen(
listOf(
SettingsToggleRow("Camera", "Allow camera tools when requested.", Icons.Default.CameraAlt, cameraEnabled, ::setCameraAccess),
SettingsToggleRow("Precise Location", "Share precise location while location is enabled.", Icons.Default.LocationOn, locationPreciseEnabled, ::setPreciseLocation),
SettingsToggleRow(
"Installed Apps",
if (installedAppsSharingEnabled) "OpenClaw can list launcher-visible apps." else "App list stays on this phone.",
Icons.Default.Storage,
installedAppsSharingEnabled,
viewModel::setInstalledAppsSharingEnabled,
),
SettingsToggleRow("Keep Awake", "Keep the node available during active work.", Icons.Default.Bolt, preventSleep, viewModel::setPreventSleep),
SettingsToggleRow("Canvas Status", "Show screen-sharing debug state.", Icons.AutoMirrored.Filled.ScreenShare, canvasDebugStatusEnabled, viewModel::setCanvasDebugStatusEnabled),
),

View File

@@ -62,6 +62,21 @@ class SecurePrefsTest {
assertFalse(plainPrefs.getBoolean("talk.enabled", false))
}
@Test
fun installedAppsSharing_defaultsOffAndPersistsOptIn() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertFalse(prefs.installedAppsSharingEnabled.value)
prefs.setInstalledAppsSharingEnabled(true)
assertTrue(prefs.installedAppsSharingEnabled.value)
assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false))
}
@Test
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
val context = RuntimeEnvironment.getApplication()

View File

@@ -9,6 +9,7 @@ import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
@@ -475,6 +476,15 @@ class ConnectionManagerTest {
assertTrue(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesDeviceAppsOnlyWhenUserOptedIn() {
val disabled = newManager(installedAppsSharingEnabled = false).buildNodeConnectOptions()
val enabled = newManager(installedAppsSharingEnabled = true).buildNodeConnectOptions()
assertFalse(disabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue))
assertTrue(enabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue))
}
@Test
fun buildNodeConnectOptions_omitsVoiceWakeWithoutMicrophonePermission() {
val options =
@@ -546,6 +556,7 @@ class ConnectionManagerTest {
callLogAvailable: Boolean = false,
photosAvailable: Boolean = false,
hasRecordAudioPermission: Boolean = false,
installedAppsSharingEnabled: Boolean = false,
): ConnectionManager {
val context = RuntimeEnvironment.getApplication()
val prefs =
@@ -567,6 +578,7 @@ class ConnectionManagerTest {
callLogAvailable = { callLogAvailable },
photosAvailable = { photosAvailable },
hasRecordAudioPermission = { hasRecordAudioPermission },
installedAppsSharingEnabled = { installedAppsSharingEnabled },
manualTls = { false },
)
}

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.node
import android.content.Context
import android.content.pm.ApplicationInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
@@ -320,6 +321,108 @@ class DeviceHandlerTest {
system["securityPatchLevel"]?.jsonPrimitive?.content
}
@Test
fun handleDeviceApps_filtersAndLimitsVisibleApps() {
val handler =
DeviceHandler.forTesting(
appContext = appContext(),
appSource =
FakeDeviceAppSource(
listOf(
DeviceAppEntry(
label = "Calendar",
packageName = "com.google.android.calendar",
system = false,
enabled = true,
launchable = true,
),
DeviceAppEntry(
label = "Android System",
packageName = "android",
system = true,
enabled = true,
launchable = false,
),
DeviceAppEntry(
label = "Disabled App",
packageName = "com.example.disabled",
system = false,
enabled = false,
launchable = true,
),
DeviceAppEntry(
label = "Gmail",
packageName = "com.google.android.gm",
system = false,
enabled = true,
launchable = true,
),
),
),
)
val result = handler.handleDeviceApps("""{"query":"google","limit":1}""")
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
assertEquals("1", payload.getValue("count").jsonPrimitive.content)
assertEquals("2", payload.getValue("totalMatched").jsonPrimitive.content)
assertTrue(payload.getValue("truncated").jsonPrimitive.boolean)
assertEquals("launcher", payload.getValue("visibility").jsonPrimitive.content)
val apps = payload.getValue("apps").jsonArray
assertEquals(1, apps.size)
val app = apps.first().jsonObject
assertEquals("Calendar", app.getValue("label").jsonPrimitive.content)
assertEquals("com.google.android.calendar", app.getValue("packageName").jsonPrimitive.content)
assertTrue(!app.getValue("system").jsonPrimitive.boolean)
assertTrue(app.getValue("enabled").jsonPrimitive.boolean)
assertTrue(app.getValue("launchable").jsonPrimitive.boolean)
}
@Test
fun handleDeviceApps_canIncludeSystemAndNonLaunchableApps() {
val source =
FakeDeviceAppSource(
listOf(
DeviceAppEntry(
label = "Android System",
packageName = "android",
system = true,
enabled = true,
launchable = false,
),
),
)
val handler = DeviceHandler.forTesting(appContext = appContext(), appSource = source)
val result = handler.handleDeviceApps("""{"includeSystem":true,"includeNonLaunchable":true}""")
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
assertEquals("android-visible", payload.getValue("visibility").jsonPrimitive.content)
assertTrue(payload.getValue("includeSystem").jsonPrimitive.boolean)
val app =
payload
.getValue("apps")
.jsonArray
.first()
.jsonObject
assertEquals("android", app.getValue("packageName").jsonPrimitive.content)
assertTrue(app.getValue("system").jsonPrimitive.boolean)
assertTrue(!app.getValue("launchable").jsonPrimitive.boolean)
assertTrue(source.includeNonLaunchableRequests.single())
}
@Test
fun isSystemDeviceApp_treatsUpdatedBuiltInsAsSystemApps() {
val appInfo =
ApplicationInfo().apply {
flags = ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
}
assertTrue(isSystemDeviceApp(appInfo))
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
private fun parsePayload(payloadJson: String?): JsonObject {
@@ -327,3 +430,14 @@ class DeviceHandlerTest {
return Json.parseToJsonElement(jsonString).jsonObject
}
}
private class FakeDeviceAppSource(
private val apps: List<DeviceAppEntry>,
) : DeviceAppSource {
val includeNonLaunchableRequests = mutableListOf<Boolean>()
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
includeNonLaunchableRequests += includeNonLaunchable
return apps
}
}

View File

@@ -115,6 +115,15 @@ class InvokeCommandRegistryTest {
assertMissingAll(commands, optionalCommands + debugCommands)
}
@Test
fun advertisedCommands_includesDeviceAppsOnlyWhenUserOptedIn() {
val disabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = false))
val enabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = true))
assertFalse(disabled.contains(OpenClawDeviceCommand.Apps.rawValue))
assertTrue(enabled.contains(OpenClawDeviceCommand.Apps.rawValue))
}
@Test
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
val commands =
@@ -151,6 +160,7 @@ class InvokeCommandRegistryTest {
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = false,
installedAppsSharingEnabled = false,
debugBuild = false,
),
)
@@ -262,6 +272,7 @@ class InvokeCommandRegistryTest {
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
installedAppsSharingEnabled: Boolean = false,
debugBuild: Boolean = false,
): NodeRuntimeFlags =
NodeRuntimeFlags(
@@ -275,6 +286,7 @@ class InvokeCommandRegistryTest {
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,
installedAppsSharingEnabled = installedAppsSharingEnabled,
debugBuild = debugBuild,
)

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
@@ -170,6 +171,20 @@ class InvokeDispatcherTest {
assertEquals("LOCATION_DISABLED: enable Location in Settings", result.error?.message)
}
@Test
fun handleInvoke_blocksDeviceAppsWhenSharingDisabled() =
runTest {
val result =
newDispatcher(installedAppsSharingEnabled = false)
.handleInvoke(OpenClawDeviceCommand.Apps.rawValue, """{"limit":1}""")
assertEquals("INSTALLED_APPS_SHARING_DISABLED", result.error?.code)
assertEquals(
"INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
result.error?.message,
)
}
@Test
fun handleInvoke_blocksMotionActivityWhenUnavailable() =
runTest {
@@ -250,6 +265,7 @@ class InvokeDispatcherTest {
smsTelephonyAvailable: Boolean = true,
callLogAvailable: Boolean = false,
photosAvailable: Boolean = true,
installedAppsSharingEnabled: Boolean = true,
debugBuild: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
@@ -297,6 +313,7 @@ class InvokeDispatcherTest {
smsTelephonyAvailable = { smsTelephonyAvailable },
callLogAvailable = { callLogAvailable },
photosAvailable = { photosAvailable },
installedAppsSharingEnabled = { installedAppsSharingEnabled },
debugBuild = { debugBuild },
onCanvasA2uiPush = {},
onCanvasA2uiReset = {},

View File

@@ -57,6 +57,7 @@ class OpenClawProtocolConstantsTest {
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
assertEquals("device.apps", OpenClawDeviceCommand.Apps.rawValue)
}
@Test

View File

@@ -514,12 +514,16 @@ extension GatewayConnection {
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(sessionKey),
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let thinking = invocation.thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
!thinking.isEmpty
{
params["thinking"] = AnyCodable(thinking)
}
if let timeout = invocation.timeoutSeconds {
params["timeout"] = AnyCodable(timeout)
}
@@ -664,7 +668,7 @@ extension GatewayConnection {
func chatSend(
sessionKey: String,
message: String,
thinking: String,
thinking: String?,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse
@@ -673,10 +677,14 @@ extension GatewayConnection {
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(resolvedKey),
"message": AnyCodable(message),
"thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey),
"timeoutMs": AnyCodable(timeoutMs),
]
if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
!thinking.isEmpty
{
params["thinking"] = AnyCodable(thinking)
}
if !attachments.isEmpty {
let encoded = attachments.map { att in

View File

@@ -387,7 +387,7 @@ actor TalkModeRuntime {
let response = try await GatewayConnection.shared.chatSend(
sessionKey: sessionKey,
message: prompt,
thinking: "low",
thinking: nil,
idempotencyKey: runId,
attachments: [])
guard self.isCurrent(gen) else { return }

View File

@@ -34,7 +34,7 @@ enum VoiceWakeForwarder {
struct ForwardOptions {
var sessionKey: String = "main"
var thinking: String = "low"
var thinking: String?
var deliver: Bool = true
var to: String?
var channel: GatewayAgentChannel = .webchat
@@ -97,7 +97,6 @@ enum VoiceWakeForwarder {
return ForwardOptions(
sessionKey: sessionKey,
thinking: "low",
deliver: true,
to: to,
channel: channel,

View File

@@ -173,9 +173,57 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
let params = json?["params"] as? [String: Any]
#expect(params?["thinking"] == nil)
#expect(params?["voiceWakeTrigger"] as? String == "")
}
@Test func `chat send omits thinking when inheriting session default`() async throws {
let recorder = WebSocketMessageRecorder()
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
recorder.append(message)
guard sendIndex > 0,
let data = Self.messageData(message),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? String
else { return }
task.emitReceiveSuccess(.data(Self.chatSendOkResponseData(id: id)))
})
})
let connection = GatewayConnection(
configProvider: {
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
},
sessionBox: WebSocketSessionBox(session: session))
_ = try await connection.chatSend(
sessionKey: "main",
message: "hello",
thinking: nil,
idempotencyKey: "chat-1",
attachments: [])
await connection.shutdown()
guard let chatMessage = recorder.snapshot().reversed().first(where: { message in
guard let data = Self.messageData(message),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return false }
return json["method"] as? String == "chat.send"
}) else {
Issue.record("expected chat.send websocket payload")
return
}
guard let payloadData = Self.messageData(chatMessage) else {
Issue.record("unexpected chat.send websocket message type")
return
}
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
let params = json?["params"] as? [String: Any]
#expect(params?["thinking"] == nil)
}
private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? {
switch message {
case let .string(text):
@@ -186,4 +234,15 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
nil
}
}
private static func chatSendOkResponseData(id: String) -> Data {
Data("""
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "runId": "chat-1", "status": "ok" }
}
""".utf8)
}
}

View File

@@ -14,7 +14,7 @@ import Testing
@Test func `forward options defaults`() {
let opts = VoiceWakeForwarder.ForwardOptions()
#expect(opts.sessionKey == "main")
#expect(opts.thinking == "low")
#expect(opts.thinking == nil)
#expect(opts.deliver == true)
#expect(opts.to == nil)
#expect(opts.channel == .webchat)
@@ -38,6 +38,7 @@ import Testing
#expect(opts.channel == .telegram)
#expect(opts.to == "telegram:6812765697")
#expect(opts.voiceWakeTrigger == "open claw")
#expect(opts.thinking == nil)
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
}

View File

@@ -1,2 +1,2 @@
63d49032a9b4dc4874a0ca17be73ecc97a2df5d1f47b4e72db34868423370558 plugin-sdk-api-baseline.json
af79f7d711afa0a8563782b8f5cdd7e46b9aea245f5e7ebc464327a8969ed65e plugin-sdk-api-baseline.jsonl
f3e0379cbe0e584a8c9658253d4a808356fe80fb5ec775bbee9e968e8d815380 plugin-sdk-api-baseline.json
601b55acafbd1e00b850c9b0c15d587029050906960071d448d37538b223e226 plugin-sdk-api-baseline.jsonl

82
docs/clawhub/cli.md Normal file
View File

@@ -0,0 +1,82 @@
---
summary: "ClawHub CLI entry points for discovering, installing, publishing, and verifying OpenClaw skills and plugins."
read_when:
- You want to use ClawHub from the command line
- You want to install ClawHub skills or plugins through OpenClaw
- You want to publish ClawHub packages
title: "ClawHub CLI"
---
# ClawHub CLI
OpenClaw has two command-line entry points for ClawHub:
- `openclaw skills` and `openclaw plugins` install and manage ClawHub packages
inside OpenClaw.
- The standalone `clawhub` CLI handles publisher workflows such as login,
publish, transfer, and sync.
## Discover and install
Use OpenClaw commands when you want to install or update packages for a local
OpenClaw agent or Gateway.
```bash
openclaw skills search "calendar"
openclaw skills install <slug>
openclaw skills update <slug>
openclaw skills verify <slug>
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>
openclaw plugins update <id-or-npm-spec>
```
Skill installs target the active workspace `skills/` directory by default. Add
`--global` to install into the shared managed skills directory.
Plugin installs use the `clawhub:` prefix when you want ClawHub resolution
instead of npm or another install source.
## Publish and maintain
Install the standalone ClawHub CLI for publisher workflows:
```bash
npm i -g clawhub
clawhub login
```
Publish plugin packages with `clawhub package publish`:
```bash
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
clawhub package publish your-org/your-plugin@v1.0.0
```
Publish skill folders with `clawhub skill publish`:
```bash
clawhub skill publish ./skills/review-helper
clawhub skill publish ./skills/review-helper --version 1.0.0
```
When local skill scan state or package ownership needs maintenance, use the
relevant standalone command:
```bash
clawhub sync --all
clawhub package transfer @old-owner/package --to new-owner
```
## Related
- [`openclaw skills`](/cli/skills) - local skill search, install, update, and
verification
- [`openclaw plugins`](/cli/plugins) - plugin search, install, update, and
inspection
- [ClawHub publishing](/clawhub/publishing) - owner scope, release validation,
and review flow
- [Creating skills](/tools/creating-skills) - skill authoring and publish flow
- [Building plugins](/plugins/building-plugins) - plugin package authoring

View File

@@ -93,6 +93,7 @@ openclaw onboard --non-interactive \
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
OpenClaw marks common vision model IDs as image-capable automatically. Pass `--custom-image-input` for unknown custom vision IDs, or `--custom-text-input` to force text-only metadata.
Use `--custom-compatibility openai-responses` for OpenAI-compatible endpoints that support `/v1/responses` but not `/v1/chat/completions`.
LM Studio also supports a provider-specific key flag in non-interactive mode:

View File

@@ -372,6 +372,30 @@ its own control markers and channel delivery.
For CLIs that emit Claude Code stream-json compatible JSONL, set
`jsonlDialect: "claude-stream-json"` on that backend's config.
## Native compaction ownership
Some CLI backends run an agent that compacts its **own** transcript, so OpenClaw must
not run its safeguard summarizer against them - doing so fights the backend's own
compaction and can hard-fail the turn.
`claude-cli` has no harness endpoint - Claude Code compacts internally - so it declares
`ownsNativeCompaction: true`, and OpenClaw returns a no-op from the compaction path.
Native-harness sessions such as Codex keep routing to their harness compaction endpoint
instead.
Because the backend owns compaction, the old stopgap of setting
`contextTokens: 1_000_000` purely to keep OpenClaw's safeguard from firing on a
claude-cli session is **no longer needed** - the opt-out replaces it.
```typescript
api.registerCliBackend({ id: "my-cli", ownsNativeCompaction: true /* ... */ });
```
Only declare `ownsNativeCompaction` for a backend that genuinely owns its compaction: it
must reliably bound its own transcript as it nears its context window and persist a
resumable session (e.g. `--resume` / `--session-id`); otherwise a deferred session can
stay over budget. Matching `agentHarnessId` sessions still route to the harness endpoint.
## Bundle MCP overlays
CLI backends do **not** receive OpenClaw tool calls directly, but a backend can

View File

@@ -469,7 +469,7 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto
- `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model.
- `allowAgents`: default allowlist of configured target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any configured target; default: same agent only). Stale entries whose agent config was deleted are rejected by `sessions_spawn` and omitted from `agents_list`; run `openclaw doctor --fix` to clean them up.
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout.
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn`. `0` means no timeout.
- `announceTimeoutMs`: per-call timeout (milliseconds) for gateway `agent` announce delivery attempts. Default: `120000`. Transient retries can make the total announce wait longer than one configured timeout.
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`.

View File

@@ -329,6 +329,7 @@ Android nodes can advertise additional command families when the corresponding c
Available families:
- `device.status`, `device.info`, `device.permissions`, `device.health`
- `device.apps` when Installed Apps sharing is enabled in Android Settings
- `notifications.list`, `notifications.actions`
- `photos.latest`
- `contacts.search`, `contacts.add`
@@ -341,12 +342,14 @@ Example invokes:
```bash
openclaw nodes invoke --node <idOrNameOrIp> --command device.status --params '{}'
openclaw nodes invoke --node <idOrNameOrIp> --command device.apps --params '{"limit":10}'
openclaw nodes invoke --node <idOrNameOrIp> --command notifications.list --params '{}'
openclaw nodes invoke --node <idOrNameOrIp> --command photos.latest --params '{"limit":1}'
```
Notes:
- `device.apps` is opt-in and returns launcher-visible apps by default.
- Motion commands are capability-gated by available sensors.
## System commands (node host / mac node)

View File

@@ -219,8 +219,9 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- By default, Android Talk uses native speech recognition, Gateway chat, and `talk.speak` through the configured gateway Talk provider. Local system TTS is used only when `talk.speak` is unavailable.
- Android Talk uses realtime Gateway relay only when `talk.realtime.mode` is `realtime` and `talk.realtime.transport` is `gateway-relay`.
- Voice wake remains disabled in the Android UX/runtime.
- Additional Android command families (availability depends on device + permissions):
- Additional Android command families (availability depends on device, permissions, and user settings):
- `device.status`, `device.info`, `device.permissions`, `device.health`
- `device.apps` only when **Settings > Phone Capabilities > Installed Apps** is enabled; it lists launcher-visible apps by default.
- `notifications.list`, `notifications.actions` (see [Notification forwarding](#notification-forwarding) below)
- `photos.latest`
- `contacts.search`, `contacts.add`

View File

@@ -208,10 +208,28 @@ only for behavior that really belongs to the backend.
| `authEpochMode` | Decide how auth changes invalidate stored CLI sessions |
| `nativeToolMode` | Declare whether the CLI has always-on native tools |
| `bundleMcp` / `bundleMcpMode` | Opt into OpenClaw's loopback MCP tool bridge |
| `ownsNativeCompaction` | Backend owns its own compaction - OpenClaw defers |
Keep these hooks provider-owned. Do not add CLI-specific branches to core when a
backend hook can express the behavior.
### `ownsNativeCompaction`: opting out of OpenClaw compaction
If your backend runs an agent that compacts its **own** transcript, set
`ownsNativeCompaction: true` so OpenClaw's safeguard summarizer never runs against its
sessions - the CLI compaction lifecycle returns a no-op and the turn proceeds. `claude-cli`
declares it because Claude Code compacts internally with no harness endpoint. Native-harness
sessions such as Codex keep routing to their harness compaction endpoint instead.
**Only declare it when all of the following hold**, or a deferred over-budget session can
stay over budget / go stale (OpenClaw no longer rescues it):
- the backend reliably compacts or bounds its own transcript as it nears its window;
- it persists a resumable session so the compacted state survives turns
(e.g. `--resume` / `--session-id`);
- it is not a native-harness compaction session - matching `agentHarnessId` sessions
route to the harness endpoint instead.
## MCP tool bridge
CLI backends do not receive OpenClaw tools by default. If the CLI can consume an

View File

@@ -58,6 +58,15 @@ explicitly to use Gemini, Voyage, Mistral, DeepInfra, Bedrock, GitHub Copilot,
Ollama, a local GGUF model, or an OpenAI-compatible `/v1/embeddings` endpoint.
Legacy configs that still say `provider: "auto"` resolve to `openai`.
<Warning>
Changing the embedding provider, model, provider settings, sources, scope,
chunking, or tokenizer can make the existing SQLite vector index incompatible.
OpenClaw pauses vector search and reports an index identity warning instead of
automatically re-embedding everything. Rebuild when you are ready with
`openclaw memory status --index --agent <id>` or
`openclaw memory index --force --agent <id>`.
</Warning>
If OpenAI embeddings are unreachable from your network, memory recall fails open
instead of blocking the turn. Set the existing `memorySearch.provider` field to a
reachable local, Ollama, regional, or OpenAI-compatible provider to restore
@@ -155,7 +164,8 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
| `outputDimensionality` | `number` | `3072` | For Embedding 2: 768, 1536, or 3072 |
<Warning>
Changing model or `outputDimensionality` triggers an automatic full reindex.
Changing model or `outputDimensionality` changes the index identity. OpenClaw
pauses vector search until you explicitly rebuild the memory index.
</Warning>
</Accordion>

View File

@@ -219,7 +219,7 @@ What you set:
- `--custom-model-id`
- `--custom-api-key` (optional; falls back to `CUSTOM_API_KEY`)
- `--custom-provider-id` (optional)
- `--custom-compatibility <openai|anthropic>` (optional; default `openai`)
- `--custom-compatibility <openai|openai-responses|anthropic>` (optional; default `openai`)
- `--custom-image-input` / `--custom-text-input` (optional; override inferred model input capability)
</Accordion>

View File

@@ -286,8 +286,9 @@ different operation limit:
openclaw config set plugins.entries.acpx.config.timeoutSeconds 180
```
Runtime turns use OpenClaw agent/run timeouts, including `/acp timeout` and
`sessions_spawn.timeoutSeconds`. Restart the gateway after changing this value.
Runtime turns use OpenClaw agent/run timeouts, including `/acp timeout`.
`sessions_spawn` does not accept per-call timeout overrides. Restart the
gateway after changing this value.
### Health probe agent configuration

View File

@@ -549,12 +549,11 @@ Two ways to start an ACP session:
`streamLogPath` pointing to a session-scoped JSONL log
(`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
</ParamField>
<ParamField path="runTimeoutSeconds" type="number">
Aborts the ACP child turn after N seconds. `0` keeps the turn on the
gateway's no-timeout path. The same value is applied to the Gateway
run and ACP runtime so stalled/quota-exhausted harnesses do not
occupy the parent agent lane indefinitely.
</ParamField>
ACP `sessions_spawn` runs use `agents.defaults.subagents.runTimeoutSeconds` for
their default child turn limit. The tool does not accept per-call timeout
overrides.
<ParamField path="model" type="string">
Explicit model override for the ACP child session. Codex ACP spawns
normalize OpenAI refs such as `openai/gpt-5.4` to Codex ACP startup

View File

@@ -141,7 +141,7 @@ session to confirm the effective tool list.
- **Model:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`). ACP runtime spawns use the same configured subagent model when present; otherwise the ACP harness keeps its own default. An explicit `sessions_spawn.model` still wins.
- **Thinking:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`). ACP runtime spawns also apply `agents.defaults.models["provider/model"].params.thinking` for the selected model. An explicit `sessions_spawn.thinking` still wins.
- **Run timeout:** if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout).
- **Run timeout:** OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). `sessions_spawn` does not accept per-call timeout overrides.
- **Task delivery:** native sub-agents receive the delegated task in their first visible `[Subagent Task]` message. The sub-agent system prompt carries runtime rules and routing context, not a hidden duplicate of the task.
Accepted native sub-agent spawns include the resolved child model metadata in
@@ -208,9 +208,6 @@ Per-agent overrides use `agents.list[].subagents.delegationMode`.
<ParamField path="thinking" type="string">
Override thinking level for the sub-agent run.
</ParamField>
<ParamField path="runTimeoutSeconds" type="number">
Defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`. When set, the sub-agent run is aborted after N seconds.
</ParamField>
<ParamField path="thread" type="boolean" default="false">
When `true`, requests channel thread binding for this sub-agent session.
</ParamField>
@@ -375,7 +372,7 @@ remain spawnable while inheriting defaults.
- Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder).
- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
- Configured run timeouts do **not** auto-archive; they only stop the run. The session remains until auto-archive.
- Auto-archive applies equally to depth-1 and depth-2 sessions.
- Browser cleanup is separate from archive cleanup: tracked browser tabs/processes are best-effort closed when the run finishes, even if the transcript/session record is kept.
@@ -394,7 +391,7 @@ worker sub-sub-agents.
maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1)
maxChildrenPerAgent: 5, // max active children per agent session (default: 5)
maxConcurrent: 8, // global concurrency lane cap (default: 8)
runTimeoutSeconds: 900, // default timeout for sessions_spawn when omitted (0 = no timeout)
runTimeoutSeconds: 900, // default timeout for sessions_spawn (0 = no timeout)
announceTimeoutMs: 120000, // per-call gateway announce timeout
},
},

View File

@@ -215,6 +215,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
agent: "codex",
mode: "persistent",
model: "gpt-5.4",
sessionOptions: { model: "gpt-5.4" },
});
});
@@ -619,7 +620,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
);
});
it("does not normalize model startup for non-Codex ACP agents", async () => {
it("passes model startup through sessionOptions for non-Codex ACP agents", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
@@ -648,6 +649,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
agent: "main",
mode: "persistent",
model: "openai/gpt-5.5",
sessionOptions: { model: "openai/gpt-5.5" },
});
});
@@ -694,6 +696,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
agent: "codex",
mode: "persistent",
model: "gpt-5.5",
sessionOptions: { model: "gpt-5.5" },
});
});
@@ -728,6 +731,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
mode: "persistent",
model: "gpt-5.4/xhigh",
thinking: "x-high",
sessionOptions: { model: "gpt-5.4/xhigh" },
});
});

View File

@@ -17,6 +17,7 @@ import {
type AcpRuntimeStatus,
type AcpRuntimeTurn,
type AcpRuntimeTurnResult,
type SessionAgentOptions,
} from "acpx/runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime";
@@ -49,6 +50,8 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
openclawProcessCleanup?: AcpxProcessCleanupDeps;
};
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
type ResetAwareSessionStore = AcpSessionStore & {
markFresh: (sessionKey: string) => void;
@@ -547,6 +550,16 @@ function codexAcpSessionModelId(override: CodexAcpModelOverride): string {
: override.model;
}
function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegateEnsureInput {
const existingOptions = (input as { sessionOptions?: SessionAgentOptions }).sessionOptions;
const model = input.model?.trim() || existingOptions?.model;
const sessionOptions = model ? { ...existingOptions, model } : existingOptions;
return {
...input,
...(sessionOptions ? { sessionOptions } : {}),
} as AcpxDelegateEnsureInput;
}
function quoteShellArg(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
@@ -942,7 +955,7 @@ export class AcpxRuntime implements AcpRuntime {
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => delegate.ensureSession(input),
run: () => delegate.ensureSession(withAcpxSessionOptions(input)),
}),
});
}
@@ -962,7 +975,7 @@ export class AcpxRuntime implements AcpRuntime {
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => delegate.ensureSession(normalizedInput),
run: () => delegate.ensureSession(withAcpxSessionOptions(normalizedInput)),
}),
),
});

View File

@@ -29,6 +29,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "always-on",
ownsNativeCompaction: true,
config: {
command: "claude",
args: [

View File

@@ -18,6 +18,8 @@ import {
import {
filterCodexDynamicTools,
resolveCodexDynamicToolsLoading,
resolveCodexDynamicToolsLoadingForModel,
shouldUseDirectCodexDynamicToolsForModel,
} from "./dynamic-tool-profile.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { createCodexTestModel } from "./test-support.js";
@@ -179,6 +181,22 @@ describe("Codex app-server dynamic tool build", () => {
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
});
it("uses direct dynamic tools for OpenAI nano models without tool_search support", () => {
const tools = [createRuntimeDynamicTool("message"), createRuntimeDynamicTool("web_search")];
const toolBridge = createCodexDynamicToolBridge({
tools,
signal: new AbortController().signal,
loading: resolveCodexDynamicToolsLoadingForModel({}, "openai/gpt-5.4-nano"),
});
expect(shouldUseDirectCodexDynamicToolsForModel("gpt-5.4-nano")).toBe(true);
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.4-nano")).toBe("direct");
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.5")).toBe("searchable");
const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search");
expect(webSearch).not.toHaveProperty("deferLoading");
expect(webSearch).not.toHaveProperty("namespace");
});
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
const messageTool = createRuntimeDynamicTool("message");
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {

View File

@@ -47,6 +47,33 @@ export function resolveCodexDynamicToolsLoading(
: (config.codexDynamicToolsLoading ?? "searchable");
}
function normalizeCodexModelId(modelId: string | undefined): string {
const normalized = modelId?.trim().toLowerCase();
if (!normalized) {
return "";
}
return normalized.includes("/") ? normalized.split("/").at(-1)! : normalized;
}
export function shouldUseDirectCodexDynamicToolsForModel(modelId: string | undefined): boolean {
return shouldDisableCodexToolSearchForModel(modelId);
}
export function shouldDisableCodexToolSearchForModel(modelId: string | undefined): boolean {
return normalizeCodexModelId(modelId) === "gpt-5.4-nano";
}
export function resolveCodexDynamicToolsLoadingForModel(
config: Pick<CodexPluginConfig, "codexDynamicToolsLoading">,
modelId: string | undefined,
env: CodexDynamicToolProfileEnv = process.env,
): CodexDynamicToolsLoading {
const loading = resolveCodexDynamicToolsLoading(config, env);
return loading === "searchable" && shouldUseDirectCodexDynamicToolsForModel(modelId)
? "direct"
: loading;
}
export function filterCodexDynamicTools<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,

View File

@@ -1652,6 +1652,81 @@ describe("CodexAppServerEventProjector", () => {
});
});
it("fails closed when a native tool call finishes without a matching result", async () => {
const trajectoryRecorder = {
filePath: "trajectory.jsonl",
recordEvent: vi.fn(),
flush: vi.fn(async () => undefined),
};
const projector = await createProjector(await createParams(), { trajectoryRecorder });
await projector.handleNotification(
forCurrentTurn("item/started", {
item: {
type: "commandExecution",
id: "cmd-denied",
command: "node scripts/report.js --publish",
cwd: "/workspace",
processId: null,
source: "agent",
status: "inProgress",
commandActions: [],
aggregatedOutput: null,
exitCode: null,
durationMs: null,
},
}),
);
await projector.handleNotification(
turnCompleted([
{
type: "agentMessage",
id: "msg-denied",
text: "The requested publish command was denied before execution.",
},
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(String(result.promptError)).toContain("without a matching tool.result");
expect(result.promptErrorSource).toBe("prompt");
expect(result.messagesSnapshot.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
"assistant",
]);
const toolResultMessage = requireRecord(result.messagesSnapshot[2], "tool result message");
expect(toolResultMessage.toolCallId).toBe("cmd-denied");
expect(toolResultMessage.toolName).toBe("bash");
expect(toolResultMessage.isError).toBe(true);
const toolResultContent = requireArray(toolResultMessage.content, "tool result content");
expect(JSON.stringify(toolResultContent)).toContain("matching tool.result");
expect(trajectoryRecorder.recordEvent).toHaveBeenCalledWith("tool.call", {
threadId: THREAD_ID,
turnId: TURN_ID,
itemId: "cmd-denied",
toolCallId: "cmd-denied",
name: "bash",
arguments: {
command: "node scripts/report.js --publish",
cwd: "/workspace",
},
});
expect(trajectoryRecorder.recordEvent).toHaveBeenCalledWith("tool.result", {
threadId: THREAD_ID,
turnId: TURN_ID,
itemId: "cmd-denied",
toolCallId: "cmd-denied",
name: "bash",
status: "failed",
isError: true,
result: { status: "failed", reason: "missing_tool_result" },
output: expect.stringContaining("without a matching tool.result"),
});
});
it("uses streamed command output when final command snapshots omit aggregated output", async () => {
const onAgentEvent = vi.fn();
const trajectoryRecorder = {

View File

@@ -109,6 +109,8 @@ const CODEX_PROMPT_TOTAL_INPUT_KEYS = [
const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20;
const TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS = 12_000;
const MISSING_TOOL_RESULT_ERROR =
"OpenClaw recorded a native Codex tool.call without a matching tool.result before the turn completed.";
const GENERATED_IMAGE_MEDIA_SUBDIR = "tool-image-generation";
const BYTES_PER_MB = 1024 * 1024;
// Match OpenClaw's default image media cap for generated image tool outputs.
@@ -172,6 +174,10 @@ export class CodexAppServerEventProjector {
private readonly toolTranscriptMessages: AgentMessage[] = [];
private readonly toolTranscriptCallIds = new Set<string>();
private readonly toolTranscriptResultIds = new Set<string>();
private readonly toolTranscriptNamesById = new Map<string, string>();
private readonly toolTrajectoryCallIds = new Set<string>();
private readonly toolTrajectoryResultIds = new Set<string>();
private readonly toolTrajectoryNamesById = new Map<string, string>();
private readonly transcriptToolProgressCallIds = new Set<string>();
private lastNativeToolError: EmbeddedRunAttemptResult["lastToolError"];
private readonly nativeGeneratedMediaUrls = new Set<string>();
@@ -185,6 +191,7 @@ export class CodexAppServerEventProjector {
private completedTurn: CodexTurn | undefined;
private promptError: unknown;
private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
private synthesizedMissingToolResultError: string | null = null;
private aborted = false;
private tokenUsage: ReturnType<typeof normalizeUsage>;
private guardianReviewCount = 0;
@@ -285,6 +292,12 @@ export class CodexAppServerEventProjector {
this.reasoningItemOrder,
).join("\n\n");
const planText = collectTextValues(this.planTextByItem).join("\n\n");
this.synthesizeMissingToolResults({
failClosed:
!this.completedTurn ||
this.completedTurn.status !== "completed" ||
assistantTexts.length > 0,
});
const lastAssistant =
assistantTexts.length > 0
? this.createAssistantMessage(assistantTexts.join("\n\n"))
@@ -328,6 +341,7 @@ export class CodexAppServerEventProjector {
const turnFailed = this.completedTurn?.status === "failed";
const promptError =
this.promptError ??
this.synthesizedMissingToolResultError ??
(turnFailed ? (this.completedTurn?.error?.message ?? "codex app-server turn failed") : null);
const agentHarnessResultClassification = classifyAgentHarnessTerminalOutcome({
assistantTexts,
@@ -1125,6 +1139,8 @@ export class CodexAppServerEventProjector {
status: ReturnType<typeof itemStatus>;
}): void {
if (params.phase === "start") {
this.toolTrajectoryCallIds.add(params.item.id);
this.toolTrajectoryNamesById.set(params.item.id, params.name);
this.options.trajectoryRecorder?.recordEvent("tool.call", {
threadId: this.threadId,
turnId: this.turnId,
@@ -1135,6 +1151,7 @@ export class CodexAppServerEventProjector {
});
return;
}
this.toolTrajectoryResultIds.add(params.item.id);
const toolResult = itemToolResult(params.item).result;
const output = itemOutputText(params.item, this.toolResultOutputTextByItem);
this.options.trajectoryRecorder?.recordEvent("tool.result", {
@@ -1396,6 +1413,7 @@ export class CodexAppServerEventProjector {
return;
}
this.toolTranscriptCallIds.add(params.id);
this.toolTranscriptNamesById.set(params.id, params.name);
this.toolTranscriptArgumentsById.set(params.id, params.arguments);
if (!shouldEmitTranscriptToolProgress(params.name, params.arguments)) {
this.transcriptToolProgressSuppressedIds.add(params.id);
@@ -1425,6 +1443,61 @@ export class CodexAppServerEventProjector {
);
}
private synthesizeMissingToolResults(params: { failClosed: boolean }): void {
if (!params.failClosed) {
return;
}
const missingTranscriptIds = [...this.toolTranscriptCallIds].filter(
(id) => !this.toolTranscriptResultIds.has(id),
);
const missingTrajectoryIds = [...this.toolTrajectoryCallIds].filter(
(id) => !this.toolTrajectoryResultIds.has(id),
);
if (missingTranscriptIds.length === 0 && missingTrajectoryIds.length === 0) {
return;
}
for (const id of missingTranscriptIds) {
const name = this.toolTranscriptNamesById.get(id) ?? this.toolTrajectoryNamesById.get(id);
if (!name) {
continue;
}
this.recordToolTranscriptResult({
id,
name,
text: formatMissingToolResultError({ id, name }),
isError: true,
});
}
for (const id of missingTrajectoryIds) {
const name = this.toolTrajectoryNamesById.get(id) ?? this.toolTranscriptNamesById.get(id);
if (!name) {
continue;
}
this.toolTrajectoryResultIds.add(id);
const text = formatMissingToolResultError({ id, name });
this.options.trajectoryRecorder?.recordEvent("tool.result", {
threadId: this.threadId,
turnId: this.turnId,
itemId: id,
toolCallId: id,
name,
status: "failed",
isError: true,
result: { status: "failed", reason: "missing_tool_result" },
output: text,
});
}
const missingCount = new Set([...missingTranscriptIds, ...missingTrajectoryIds]).size;
this.synthesizedMissingToolResultError =
missingCount === 1
? MISSING_TOOL_RESULT_ERROR
: `${MISSING_TOOL_RESULT_ERROR} missingToolResultCount=${missingCount}`;
this.promptErrorSource = this.promptErrorSource ?? "prompt";
}
private emitTranscriptToolCallProgress(params: ToolTranscriptCallInput): void {
if (!shouldEmitTranscriptToolProgress(params.name, params.arguments)) {
return;
@@ -1954,6 +2027,10 @@ function itemStatus(item: CodexThreadItem): "completed" | "failed" | "running" |
return "completed";
}
function formatMissingToolResultError(params: { id: string; name: string }): string {
return `${MISSING_TOOL_RESULT_ERROR} toolCallId=${params.id}; toolName=${params.name}`;
}
function isNonSuccessItemStatus(status: ReturnType<typeof itemStatus>): boolean {
return status === "failed" || status === "blocked";
}

View File

@@ -165,7 +165,7 @@ import {
} from "./dynamic-tool-execution.js";
import {
filterCodexDynamicTools,
resolveCodexDynamicToolsLoading,
resolveCodexDynamicToolsLoadingForModel,
} from "./dynamic-tool-profile.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
@@ -595,7 +595,7 @@ export async function runCodexAppServerAttempt(
tools,
registeredTools,
signal: runAbortController.signal,
loading: resolveCodexDynamicToolsLoading(pluginConfig),
loading: resolveCodexDynamicToolsLoadingForModel(pluginConfig, params.modelId),
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
hookContext: {
agentId: sessionAgentId,
@@ -2640,7 +2640,7 @@ export const testing = {
buildDynamicTools,
filterCodexDynamicToolsForAllowlist,
includeForcedCodexDynamicToolAllow,
resolveCodexDynamicToolsLoading,
resolveCodexDynamicToolsLoadingForModel,
resolveCodexAppServerHookChannelId,
buildCodexAppServerPromptTimeoutOutcome,
resolveOpenClawCodingToolsSessionKeys,

View File

@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import { sendResult } from "./sandbox-exec-server/json-rpc.js";
function createSocket() {
return {
send: vi.fn(),
} as unknown as WebSocket & { send: ReturnType<typeof vi.fn> };
}
function sentJson(socket: ReturnType<typeof createSocket>) {
return JSON.parse(String(socket.send.mock.calls[0]?.[0])) as unknown;
}
describe("sandbox exec-server JSON-RPC helpers", () => {
it("preserves explicit null results", () => {
const socket = createSocket();
sendResult(socket, 1, null);
expect(sentJson(socket)).toEqual({ jsonrpc: "2.0", id: 1, result: null });
});
it("keeps undefined results as empty objects for methods without bodies", () => {
const socket = createSocket();
sendResult(socket, 2, undefined);
expect(sentJson(socket)).toEqual({ jsonrpc: "2.0", id: 2, result: {} });
});
});

View File

@@ -80,7 +80,9 @@ export function sendResult(
id: string | number,
result: JsonValue | undefined,
): void {
socket.send(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }));
socket.send(
JSON.stringify({ jsonrpc: "2.0", id, result: result === undefined ? {} : result }),
);
}
export function sendError(

View File

@@ -40,6 +40,7 @@ export type CodexAppServerThreadBinding = {
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
dynamicToolsFingerprint?: string;
dynamicToolsContainDeferred?: boolean;
userMcpServersFingerprint?: string;
mcpServersFingerprint?: string;
nativeHookRelayGeneration?: string;
@@ -111,6 +112,10 @@ export async function readCodexAppServerBinding(
typeof parsed.dynamicToolsFingerprint === "string"
? parsed.dynamicToolsFingerprint
: undefined,
dynamicToolsContainDeferred:
typeof parsed.dynamicToolsContainDeferred === "boolean"
? parsed.dynamicToolsContainDeferred
: undefined,
userMcpServersFingerprint:
typeof parsed.userMcpServersFingerprint === "string"
? parsed.userMcpServersFingerprint
@@ -170,6 +175,7 @@ export async function writeCodexAppServerBinding(
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
userMcpServersFingerprint: binding.userMcpServersFingerprint,
mcpServersFingerprint: binding.mcpServersFingerprint,
nativeHookRelayGeneration: binding.nativeHookRelayGeneration,

View File

@@ -63,6 +63,16 @@ function createNamedDynamicTool(
};
}
function createDeferredNamedDynamicTool(
name: string,
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
return {
...createNamedDynamicTool(name),
namespace: "openclaw",
deferLoading: true,
};
}
function createPluginAppConfigPatch() {
return {
apps: {
@@ -243,6 +253,42 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("starts a fresh Codex thread when dynamic tools switch from deferred to direct", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("web_search")],
appServer,
});
expect(binding.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
it("resumes a bound Codex thread when dynamic tools are reordered", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -489,7 +535,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createMessageDynamicTool("Send and manage messages.")],
dynamicTools: [createDeferredNamedDynamicTool("message")],
appServer,
});
const fingerprint = (await readCodexAppServerBinding(sessionFile))?.dynamicToolsFingerprint;
@@ -504,12 +550,13 @@ describe("Codex app-server thread lifecycle bindings", () => {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createMessageDynamicTool("Send and manage messages.")],
dynamicTools: [createDeferredNamedDynamicTool("message")],
appServer,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.dynamicToolsFingerprint).toBe(fingerprint);
expect(binding?.dynamicToolsContainDeferred).toBe(true);
expect(binding?.threadId).toBe("thread-1");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",

View File

@@ -1,5 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
import {
buildDeveloperInstructions,
@@ -8,8 +11,15 @@ import {
buildThreadResumeParams,
buildThreadStartParams,
codexDynamicToolsFingerprint,
formatCodexThreadLifecycleTimingSummary,
resolveReasoningEffort,
shouldWarnCodexThreadLifecycleTimingSummary,
startOrResumeThread,
type CodexThreadLifecycleTimingLogger,
} from "./thread-lifecycle.js";
import { createCodexTestModel } from "./test-support.js";
let tempDir: string;
function createAttemptParams(params: {
provider: string;
@@ -21,6 +31,7 @@ function createAttemptParams(params: {
bootstrapContextMode?: "full" | "lightweight";
bootstrapContextRunKind?: "default" | "heartbeat" | "cron";
images?: EmbeddedRunAttemptParams["images"];
modelId?: string;
}): EmbeddedRunAttemptParams {
const authProfileProviders =
params.authProfileProviders ??
@@ -30,7 +41,7 @@ function createAttemptParams(params: {
const authProfileType = params.authProfileType ?? "oauth";
return {
provider: params.provider,
modelId: "gpt-5.4",
modelId: params.modelId ?? "gpt-5.4",
prompt: "test prompt",
authProfileId: params.authProfileId,
...(params.bootstrapContextMode ? { bootstrapContextMode: params.bootstrapContextMode } : {}),
@@ -73,6 +84,102 @@ function createAppServerOptions() {
} as const;
}
function createThreadLifecycleParams(
sessionFile: string,
workspaceDir: string,
): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir,
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4-codex",
model: createCodexTestModel("codex"),
thinkLevel: "medium",
disableTools: true,
timeoutMs: 5_000,
authStorage: {} as never,
authProfileStore: { version: 1, profiles: {} },
modelRegistry: {} as never,
} as EmbeddedRunAttemptParams;
}
function createThreadLifecycleAppServerOptions(): Parameters<
typeof startOrResumeThread
>[0]["appServer"] {
return {
start: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
codeModeOnly: false,
requestTimeoutMs: 60_000,
turnCompletionIdleTimeoutMs: 60_000,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
};
}
function threadStartResult(threadId = "thread-1") {
return {
thread: {
id: threadId,
sessionId: "session-1",
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.4-codex",
modelProvider: "openai",
serviceTier: null,
cwd: tempDir,
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
function createTimingLogger(traceEnabled: boolean): CodexThreadLifecycleTimingLogger {
return {
isEnabled: vi.fn((level: "trace") => level === "trace" && traceEnabled),
trace: vi.fn(),
warn: vi.fn(),
};
}
function expectSingleLogMessage(
log: CodexThreadLifecycleTimingLogger,
level: "trace" | "warn",
): string {
const mock = log[level] as ReturnType<typeof vi.fn>;
expect(mock).toHaveBeenCalledTimes(1);
const message = mock.mock.calls[0]?.[0];
expect(typeof message).toBe("string");
return message as string;
}
describe("Codex app-server native code mode config", () => {
it("keeps Codex-native subagents primary while limiting OpenClaw spawn to OpenClaw delegation", () => {
const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }));
@@ -151,7 +258,7 @@ describe("Codex app-server native code mode config", () => {
expect(instructions).not.toContain("Deferred searchable OpenClaw dynamic tools available");
});
it("keeps durable dynamic tool fingerprints independent from presentation mode", () => {
it("keeps durable dynamic tool fingerprints scoped to loading mode", () => {
const inputSchema = {
type: "object",
additionalProperties: false,
@@ -177,7 +284,7 @@ describe("Codex app-server native code mode config", () => {
},
]);
expect(searchableFingerprint).toBe(directFingerprint);
expect(searchableFingerprint).not.toBe(directFingerprint);
});
it("keeps OpenClaw skill catalogs out of developer instructions", () => {
@@ -214,6 +321,25 @@ describe("Codex app-server native code mode config", () => {
expect(request.personality).toBe("none");
});
it("disables Codex tool-search features for nano models", () => {
const request = buildThreadStartParams(
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
{
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
},
);
expect(request.config).toEqual({
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.multi_agent": false,
});
});
it("removes Codex model personality on thread/resume", () => {
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
@@ -674,6 +800,176 @@ describe("Codex app-server model provider selection", () => {
});
});
describe("Codex app-server thread lifecycle timing", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-thread-lifecycle-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("formats stage summaries with run, session, action, and elapsed timing", () => {
const message = formatCodexThreadLifecycleTimingSummary({
runId: "run-a",
sessionId: "session-a",
sessionKey: "agent:main:session-a",
action: "started",
summary: {
totalMs: 12,
spans: [
{ name: "read-binding", durationMs: 4, elapsedMs: 4 },
{ name: "thread-start-request", durationMs: 8, elapsedMs: 12 },
],
},
});
expect(message).toBe(
"[trace:codex-app-server] thread lifecycle: runId=run-a sessionId=session-a " +
"sessionKey=agent:main:session-a action=started totalMs=12 " +
"stages=read-binding:4ms@4ms,thread-start-request:8ms@12ms",
);
});
it("warns when the total or a single stage crosses the lifecycle threshold", () => {
expect(
shouldWarnCodexThreadLifecycleTimingSummary(
{
totalMs: 9,
spans: [{ name: "thread-start-request", durationMs: 10, elapsedMs: 10 }],
},
{ totalThresholdMs: 50, stageThresholdMs: 10 },
),
).toBe(true);
expect(
shouldWarnCodexThreadLifecycleTimingSummary(
{
totalMs: 50,
spans: [{ name: "thread-start-request", durationMs: 1, elapsedMs: 1 }],
},
{ totalThresholdMs: 50, stageThresholdMs: 10 },
),
).toBe(true);
});
it("emits a trace stage summary when starting a new thread with trace enabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
let nowMs = 0;
const log = createTimingLogger(true);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
nowMs += 17;
return threadStartResult("thread-started");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createThreadLifecycleParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
timing: {
enabled: true,
now: () => nowMs,
log,
totalThresholdMs: 1_000,
stageThresholdMs: 1_000,
},
});
const message = expectSingleLogMessage(log, "trace");
expect(log.warn).not.toHaveBeenCalled();
expect(message).toContain("action=started");
expect(message).toContain("thread-start-request:17ms@17ms");
expect(message).toContain("thread-ready:0ms@17ms");
});
it("emits a trace stage summary when resuming an existing thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
let nowMs = 0;
const log = createTimingLogger(true);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-existing");
}
if (method === "thread/resume") {
nowMs += 9;
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
const commonParams = {
client: { request } as never,
params: createThreadLifecycleParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
};
await startOrResumeThread({
...commonParams,
timing: {
enabled: true,
now: () => nowMs,
log: createTimingLogger(false),
},
});
await startOrResumeThread({
...commonParams,
timing: {
enabled: true,
now: () => nowMs,
log,
totalThresholdMs: 1_000,
stageThresholdMs: 1_000,
},
});
const message = expectSingleLogMessage(log, "trace");
expect(message).toContain("action=resumed");
expect(message).toContain("thread-resume-request:9ms@9ms");
});
it("warns on slow start even when trace logging is disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
let nowMs = 0;
const log = createTimingLogger(false);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
nowMs += 25;
return threadStartResult("thread-slow");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createThreadLifecycleParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
timing: {
enabled: true,
now: () => nowMs,
log,
totalThresholdMs: 10,
stageThresholdMs: 10,
},
});
const message = expectSingleLogMessage(log, "warn");
expect(log.trace).not.toHaveBeenCalled();
expect(message).toContain("action=started");
expect(message).toContain("thread-start-request:25ms@25ms");
});
});
describe("resolveReasoningEffort (#71946)", () => {
describe("modern Codex models (none/low/medium/high/xhigh enum)", () => {
it.each(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"] as const)(

View File

@@ -20,6 +20,7 @@ import {
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
} from "./context-engine-projection.js";
import { shouldDisableCodexToolSearchForModel } from "./dynamic-tool-profile.js";
import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js";
import {
isCodexPluginThreadBindingStale,
@@ -114,32 +115,88 @@ const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = {
project_doc_max_bytes: 0,
};
type CodexThreadLifecycleTimingSpan = {
const CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG: JsonObject = {
"features.multi_agent": false,
};
export type CodexThreadLifecycleTimingSpan = {
name: string;
durationMs: number;
elapsedMs: number;
};
type CodexThreadLifecycleTimingSummary = {
export type CodexThreadLifecycleTimingSummary = {
totalMs: number;
spans: CodexThreadLifecycleTimingSpan[];
};
export type CodexThreadLifecycleTimingLogger = {
isEnabled?: (level: "trace") => boolean;
trace: (message: string, meta?: Record<string, unknown>) => void;
warn: (message: string, meta?: Record<string, unknown>) => void;
};
export type CodexThreadLifecycleTimingAction = "started" | "resumed" | "rotated";
export type CodexThreadLifecycleTimingOptions = {
enabled?: boolean;
now?: () => number;
log?: CodexThreadLifecycleTimingLogger;
totalThresholdMs?: number;
stageThresholdMs?: number;
};
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS = 1_000;
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS = 500;
function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean } = {}): {
export function shouldWarnCodexThreadLifecycleTimingSummary(
summary: CodexThreadLifecycleTimingSummary,
options: CodexThreadLifecycleTimingOptions = {},
): boolean {
const totalThresholdMs =
options.totalThresholdMs ?? CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS;
const stageThresholdMs =
options.stageThresholdMs ?? CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS;
return (
summary.totalMs >= totalThresholdMs ||
summary.spans.some((span) => span.durationMs >= stageThresholdMs)
);
}
export function formatCodexThreadLifecycleTimingSummary(params: {
runId: string;
sessionId: string;
sessionKey?: string;
action: CodexThreadLifecycleTimingAction;
summary: CodexThreadLifecycleTimingSummary;
}): string {
const spans =
params.summary.spans.length > 0
? params.summary.spans
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
.join(",")
: "none";
return (
`[trace:codex-app-server] thread lifecycle: runId=${params.runId} ` +
`sessionId=${params.sessionId} sessionKey=${params.sessionKey ?? "unknown"} ` +
`action=${params.action} totalMs=${params.summary.totalMs} stages=${spans}`
);
}
function createCodexThreadLifecycleTimingTracker(options: CodexThreadLifecycleTimingOptions = {}): {
measure: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
measureSync: <T>(name: string, run: () => T) => T;
logIfSlow: (params: {
mark: (name: string) => void;
logSummary: (params: {
runId: string;
sessionId: string;
sessionKey?: string;
action: "started" | "resumed" | "rotated";
action: CodexThreadLifecycleTimingAction;
threadId?: string;
}) => void;
} {
if (!options.enabled) {
const log = options.log ?? embeddedAgentLog;
if (!options.enabled && log.isEnabled?.("trace") !== true) {
return {
async measure(_name, run) {
return await run();
@@ -147,37 +204,31 @@ function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean }
measureSync(_name, run) {
return run();
},
logIfSlow() {},
mark() {},
logSummary() {},
};
}
const startedAt = Date.now();
const now = options.now ?? Date.now;
const startedAt = now();
let didLog = false;
const spans: CodexThreadLifecycleTimingSpan[] = [];
const toMs = (value: number) => Math.max(0, Math.round(value));
const record = (name: string, spanStartedAt: number) => {
const currentAt = now();
spans.push({
name,
durationMs: toMs(Date.now() - spanStartedAt),
elapsedMs: toMs(Date.now() - startedAt),
durationMs: toMs(currentAt - spanStartedAt),
elapsedMs: toMs(currentAt - startedAt),
});
};
const snapshot = (): CodexThreadLifecycleTimingSummary => ({
totalMs: toMs(Date.now() - startedAt),
totalMs: toMs(now() - startedAt),
spans: spans.slice(),
});
const shouldLog = (summary: CodexThreadLifecycleTimingSummary) =>
summary.totalMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS ||
summary.spans.some((span) => span.durationMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS);
const formatSpans = (summary: CodexThreadLifecycleTimingSummary) =>
summary.spans.length > 0
? summary.spans
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
.join(",")
: "none";
return {
async measure(name, run) {
const spanStartedAt = Date.now();
const spanStartedAt = now();
try {
return await run();
} finally {
@@ -185,38 +236,47 @@ function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean }
}
},
measureSync(name, run) {
const spanStartedAt = Date.now();
const spanStartedAt = now();
try {
return run();
} finally {
record(name, spanStartedAt);
}
},
logIfSlow(params) {
mark(name) {
record(name, now());
},
logSummary(params) {
if (didLog) {
return;
}
const summary = snapshot();
if (!shouldLog(summary)) {
const shouldWarn = shouldWarnCodexThreadLifecycleTimingSummary(summary, options);
if (!shouldWarn && !log.isEnabled?.("trace")) {
return;
}
didLog = true;
embeddedAgentLog.warn(
`codex app-server thread lifecycle timings runId=${params.runId} sessionId=${
params.sessionId
} sessionKey=${params.sessionKey ?? "unknown"} action=${params.action} totalMs=${
summary.totalMs
} stages=${formatSpans(summary)}`,
{
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
action: params.action,
threadId: params.threadId,
totalMs: summary.totalMs,
spans: summary.spans,
},
);
const message = formatCodexThreadLifecycleTimingSummary({
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
action: params.action,
summary,
});
const meta = {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
action: params.action,
threadId: params.threadId,
totalMs: summary.totalMs,
spans: summary.spans,
};
if (shouldWarn) {
log.warn(message, meta);
} else {
log.trace(message, meta);
}
},
};
}
@@ -244,16 +304,22 @@ export async function startOrResumeThread(params: {
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
signal?: AbortSignal;
timing?: CodexThreadLifecycleTimingOptions;
}): Promise<CodexAppServerThreadLifecycleBinding> {
// Thread lifecycle spans are useful when profiling startup churn, but normal
// turns should not pay Date.now/span-array overhead while resuming threads.
const lifecycleTiming = createCodexThreadLifecycleTimingTracker({
enabled: isCodexAppServerProfilerEnabled(params.params.config),
...params.timing,
enabled:
params.timing?.enabled ?? isCodexAppServerProfilerEnabled(params.params.config),
});
const dynamicToolsFingerprint = lifecycleTiming.measureSync("fingerprint_dynamic_tools", () =>
const dynamicToolsFingerprint = lifecycleTiming.measureSync("dynamic-tools-fingerprint", () =>
fingerprintDynamicTools(params.dynamicTools),
);
const contextEngineBinding = lifecycleTiming.measureSync("context_engine_binding", () =>
const dynamicToolsContainDeferred = params.dynamicTools.some(
(tool) => tool.deferLoading === true,
);
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
buildContextEngineBinding(params.params, params.contextEngineProjection),
);
const userMcpServersConfigPatch =
@@ -266,7 +332,7 @@ export async function startOrResumeThread(params: {
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
params.environmentSelection,
);
let binding = await lifecycleTiming.measure("read_binding", () =>
let binding = await lifecycleTiming.measure("read-binding", () =>
readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
@@ -373,7 +439,7 @@ export async function startOrResumeThread(params: {
})
) {
try {
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin_config_recovery", () =>
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin-config-recovery", () =>
params.pluginThreadConfig?.build(),
);
pluginBindingStale =
@@ -404,6 +470,23 @@ export async function startOrResumeThread(params: {
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (binding?.threadId) {
if (
binding.dynamicToolsFingerprint &&
params.dynamicTools.length > 0 &&
binding.dynamicToolsContainDeferred !== dynamicToolsContainDeferred &&
(binding.dynamicToolsContainDeferred !== undefined || !dynamicToolsContainDeferred)
) {
embeddedAgentLog.debug(
"codex app-server dynamic tool loading changed; starting a new thread",
{
threadId: binding.threadId,
},
);
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
}
if (binding?.threadId) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
@@ -449,7 +532,7 @@ export async function startOrResumeThread(params: {
userMcpServersConfigPatch,
finalConfigPatch.configPatch,
);
const resumeParams = lifecycleTiming.measureSync("thread_resume_params", () =>
const resumeParams = lifecycleTiming.measureSync("thread-resume-params", () =>
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
authProfileId,
@@ -462,7 +545,7 @@ export async function startOrResumeThread(params: {
}),
);
const response = assertCodexThreadResumeResponse(
await lifecycleTiming.measure("thread_resume_request", () =>
await lifecycleTiming.measure("thread-resume-request", () =>
params.client.request("thread/resume", resumeParams, { signal: params.signal }),
),
);
@@ -479,7 +562,7 @@ export async function startOrResumeThread(params: {
params.mcpServersFingerprintEvaluated === true
? params.mcpServersFingerprint
: binding.mcpServersFingerprint;
await lifecycleTiming.measure("thread_resume_write_binding", () =>
await lifecycleTiming.measure("thread-resume-write-binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
{
@@ -489,6 +572,7 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration:
@@ -518,7 +602,8 @@ export async function startOrResumeThread(params: {
action: "resumed",
});
}
lifecycleTiming.logIfSlow({
lifecycleTiming.mark("thread-ready");
lifecycleTiming.logSummary({
runId: params.params.runId,
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
@@ -533,6 +618,7 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration:
@@ -558,7 +644,7 @@ export async function startOrResumeThread(params: {
const pluginThreadConfig = params.pluginThreadConfig?.enabled
? (prebuiltPluginThreadConfig ??
(await lifecycleTiming.measure("plugin_config_build", () =>
(await lifecycleTiming.measure("plugin-config-build", () =>
params.pluginThreadConfig?.build(),
)))
: undefined;
@@ -566,7 +652,7 @@ export async function startOrResumeThread(params: {
configPatch: params.finalConfigPatch,
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
};
const config = lifecycleTiming.measureSync("merge_thread_config", () =>
const config = lifecycleTiming.measureSync("merge-thread-config", () =>
mergeCodexThreadConfigs(
params.config,
userMcpServersConfigPatch,
@@ -574,7 +660,7 @@ export async function startOrResumeThread(params: {
finalConfigPatch.configPatch,
),
);
const startParams = lifecycleTiming.measureSync("thread_start_params", () =>
const startParams = lifecycleTiming.measureSync("thread-start-params", () =>
buildThreadStartParams(params.params, {
cwd: params.cwd,
dynamicTools: params.dynamicTools,
@@ -586,7 +672,7 @@ export async function startOrResumeThread(params: {
environmentSelection: params.environmentSelection,
}),
);
const threadStartResponse = await lifecycleTiming.measure("thread_start_request", async () => {
const threadStartResponse = await lifecycleTiming.measure("thread-start-request", async () => {
try {
return await params.client.request("thread/start", startParams, { signal: params.signal });
} catch (error) {
@@ -609,7 +695,7 @@ export async function startOrResumeThread(params: {
const nextMcpServersFingerprint =
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
if (!preserveExistingBinding) {
await lifecycleTiming.measure("thread_start_write_binding", () =>
await lifecycleTiming.measure("thread-start-write-binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
{
@@ -619,6 +705,7 @@ export async function startOrResumeThread(params: {
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
@@ -648,7 +735,8 @@ export async function startOrResumeThread(params: {
});
}
}
lifecycleTiming.logIfSlow({
lifecycleTiming.mark("thread-ready");
lifecycleTiming.logSummary({
runId: params.params.runId,
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
@@ -664,6 +752,7 @@ export async function startOrResumeThread(params: {
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
@@ -924,7 +1013,14 @@ function buildCodexRuntimeThreadConfigForRun(
config: JsonObject | undefined,
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
): JsonObject {
const runtimeConfig = buildCodexRuntimeThreadConfig(config, options);
const baseConfig = buildCodexRuntimeThreadConfig(config, options);
const runtimeConfig =
mergeCodexThreadConfigs(
baseConfig,
shouldDisableCodexToolSearchForModel(params.modelId)
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
: undefined,
) ?? baseConfig;
if (params.bootstrapContextMode !== "lightweight") {
return runtimeConfig;
}
@@ -1114,9 +1210,7 @@ function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
for (const [key, child] of Object.entries(tool).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
// Tool-search presentation can change per turn without changing the
// durable app-server execution contract for an existing thread.
if (key === "description" || key === "deferLoading" || key === "namespace") {
if (key === "description") {
continue;
}
stable[key] = stabilizeJsonValue(child);

View File

@@ -1,6 +1,11 @@
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const telemetryState = vi.hoisted(() => {
type TestSpanContext = {
traceId: string;
spanId: string;
traceFlags: number;
};
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
const histograms = new Map<string, { record: ReturnType<typeof vi.fn> }>();
const spans: Array<{
@@ -9,7 +14,7 @@ const telemetryState = vi.hoisted(() => {
end: ReturnType<typeof vi.fn>;
setAttributes: ReturnType<typeof vi.fn>;
setStatus: ReturnType<typeof vi.fn>;
spanContext: ReturnType<typeof vi.fn>;
spanContext: ReturnType<typeof vi.fn<() => TestSpanContext>>;
}> = [];
const tracer = {
startSpan: vi.fn((name: string, _opts?: unknown, _ctx?: unknown) => {
@@ -20,7 +25,7 @@ const telemetryState = vi.hoisted(() => {
end: vi.fn(),
setAttributes: vi.fn(),
setStatus: vi.fn(),
spanContext: vi.fn(() => ({
spanContext: vi.fn<() => TestSpanContext>(() => ({
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
spanId,
traceFlags: 1,
@@ -156,13 +161,21 @@ vi.mock("@opentelemetry/semantic-conventions", () => ({
}));
import {
createDiagnosticTraceContext,
emitTrustedDiagnosticEvent,
emitTrustedDiagnosticEventWithPrivateData,
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
waitForDiagnosticEventsDrained,
type DiagnosticEventPrivateData,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { onTrustedInternalDiagnosticEvent } from "openclaw/plugin-sdk/plugin-test-runtime";
import {
emitInternalDiagnosticEventForTest,
logMessageDispatchStarted,
logMessageProcessed,
onTrustedInternalDiagnosticEvent,
runWithDiagnosticTraceContext,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { OpenClawPluginServiceContext } from "../api.js";
import { emitDiagnosticEvent } from "../api.js";
import { createDiagnosticsOtelService } from "./service.js";
@@ -175,6 +188,12 @@ const SPAN_ID = "00f067aa0ba902b7";
const CHILD_SPAN_ID = "1111111111111111";
const GRANDCHILD_SPAN_ID = "2222222222222222";
const TOOL_SPAN_ID = "3333333333333333";
const MODEL_CALL_SPAN_ID = "4444444444444444";
const MODEL_USAGE_SPAN_ID = "5555555555555555";
function numberedSpanId(index: number) {
return (index + 0x1000).toString(16).padStart(16, "0");
}
const PROTO_KEY = "__proto__";
const MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS = 128 * 1024;
const OTEL_TRUNCATED_SUFFIX_MAX_CHARS = 20;
@@ -249,6 +268,27 @@ function startedSpanOptions(name: string) {
return startedSpanCall(name)?.[1];
}
function startedSpanParentContexts(name: string) {
return telemetryState.tracer.startSpan.mock.calls
.filter((call) => call[0] === name)
.map(
(call) =>
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
?.spanContext,
);
}
function startedSpanParentContextsByName(name: string) {
return telemetryState.tracer.startSpan.mock.calls
.filter((call) => call[0] === name)
.map((call) => ({
attributes: (call[1] as { attributes?: Record<string, unknown> } | undefined)?.attributes,
parentContext: (
call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined
)?.spanContext,
}));
}
function mockCall(mock: { mock: { calls: unknown[][] } }, callIndex = 0): unknown[] {
const call = mock.mock.calls.at(callIndex);
if (!call) {
@@ -2563,7 +2603,581 @@ describe("diagnostics-otel service", () => {
await service.stop?.(ctx);
});
test("keeps trusted run spans alive long enough for post-completion usage parenting", async () => {
test("correlates one channel message waterfall across message, harness, usage, and model spans", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "message.dispatch.started",
channel: "slack",
source: "replyResolver",
sessionKey: "agent:main:slack:channel:c1",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "harness.run.started",
runId: "run-1",
harnessId: "codex",
pluginId: "codex",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
trace: {
traceId: TRACE_ID,
spanId: TOOL_SPAN_ID,
parentSpanId: GRANDCHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.call.started",
runId: "run-1",
callId: "call-1",
provider: "openai",
model: "gpt-5.5",
api: "openai-codex-responses",
transport: "stdio",
trace: {
traceId: TRACE_ID,
spanId: MODEL_CALL_SPAN_ID,
parentSpanId: TOOL_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: "call-1",
provider: "openai",
model: "gpt-5.5",
api: "openai-codex-responses",
transport: "stdio",
durationMs: 80,
trace: {
traceId: TRACE_ID,
spanId: MODEL_CALL_SPAN_ID,
parentSpanId: TOOL_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "harness.run.completed",
runId: "run-1",
harnessId: "codex",
pluginId: "codex",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
durationMs: 100,
outcome: "completed",
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.usage",
sessionKey: "agent:main:slack:channel:c1",
channel: "slack",
agentId: "main",
provider: "openai",
model: "gpt-5.5",
usage: { input: 3, output: 2, total: 5 },
durationMs: 10,
trace: {
traceId: TRACE_ID,
spanId: MODEL_USAGE_SPAN_ID,
parentSpanId: GRANDCHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "message.processed",
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await flushDiagnosticEvents();
const messageSpan = spanByName("openclaw.message.processed");
const harnessSpan = spanByName("openclaw.harness.run");
const runSpan = spanByName("openclaw.run");
const usageSpan = spanByName("openclaw.model.usage");
const modelCallSpan = spanByName("openclaw.model.call");
const messageSpanContext = messageSpan.spanContext();
const harnessSpanContext = harnessSpan.spanContext();
const runSpanContext = runSpan.spanContext();
const usageSpanContext = usageSpan.spanContext();
const modelCallSpanContext = modelCallSpan.spanContext();
const parentBySpanName = Object.fromEntries(
telemetryState.tracer.startSpan.mock.calls.map((call) => [
call[0],
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
?.spanContext,
]),
);
expect(messageSpanContext.traceId).toBe(TRACE_ID);
expect(harnessSpanContext.traceId).toBe(TRACE_ID);
expect(usageSpanContext.traceId).toBe(TRACE_ID);
expect(modelCallSpanContext.traceId).toBe(TRACE_ID);
expect(parentBySpanName["openclaw.message.processed"]?.spanId).toBe(SPAN_ID);
expect(parentBySpanName["openclaw.harness.run"]?.spanId).toBe(messageSpanContext.spanId);
expect(parentBySpanName["openclaw.run"]?.spanId).toBe(harnessSpanContext.spanId);
expect(parentBySpanName["openclaw.model.usage"]?.spanId).toBe(harnessSpanContext.spanId);
expect(parentBySpanName["openclaw.model.call"]?.spanId).toBe(runSpanContext.spanId);
await service.stop?.(ctx);
});
test("uses production message lifecycle helpers as the message span anchor", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageDispatchStarted({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
source: "replyResolver",
});
emitTrustedDiagnosticEvent({
type: "harness.run.started",
runId: "run-1",
harnessId: "codex",
pluginId: "codex",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.usage",
sessionKey: "agent:main:slack:channel:c1",
channel: "slack",
agentId: "main",
provider: "openai",
model: "gpt-5.5",
usage: { input: 3, output: 2, total: 5 },
durationMs: 10,
trace: {
traceId: TRACE_ID,
spanId: MODEL_USAGE_SPAN_ID,
parentSpanId: GRANDCHILD_SPAN_ID,
traceFlags: "01",
},
});
logMessageProcessed({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
});
});
await flushDiagnosticEvents();
const messageSpan = spanByName("openclaw.message.processed");
const harnessSpan = spanByName("openclaw.harness.run");
const messageSpanContext = messageSpan.spanContext();
const harnessSpanContext = harnessSpan.spanContext();
const parentBySpanName = Object.fromEntries(
telemetryState.tracer.startSpan.mock.calls.map((call) => [
call[0],
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
?.spanContext,
]),
);
expect(parentBySpanName["openclaw.message.processed"]?.spanId).toBe(SPAN_ID);
expect(parentBySpanName["openclaw.harness.run"]?.spanId).toBe(messageSpanContext.spanId);
expect(parentBySpanName["openclaw.model.usage"]?.spanId).toBe(harnessSpanContext.spanId);
expect(messageSpanContext.traceId).toBe(TRACE_ID);
expect(harnessSpanContext.traceId).toBe(TRACE_ID);
await service.stop?.(ctx);
});
test("does not force a remote parent for root message lifecycle helpers", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageDispatchStarted({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
source: "replyResolver",
});
logMessageProcessed({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
});
});
await flushDiagnosticEvents();
expect(spanByName("openclaw.message.processed").spanContext().traceId).toBe(TRACE_ID);
expect(startedSpanParentContexts("openclaw.message.processed")[0]).toBeUndefined();
await service.stop?.(ctx);
});
test("parents outbound delivery spans under the active message lifecycle span", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageDispatchStarted({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
source: "replyResolver",
});
emitInternalDiagnosticEventForTest({
type: "message.delivery.completed",
channel: "slack",
deliveryKind: "text",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 15,
resultCount: 1,
});
emitInternalDiagnosticEventForTest({
type: "message.delivery.error",
channel: "slack",
deliveryKind: "media",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 25,
errorCategory: "network",
});
logMessageProcessed({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
});
});
await flushDiagnosticEvents();
const messageSpanContext = spanByName("openclaw.message.processed").spanContext();
const deliveryParentContexts = startedSpanParentContexts("openclaw.message.delivery");
expect(deliveryParentContexts).toHaveLength(2);
expect(deliveryParentContexts[0]?.traceId).toBe(TRACE_ID);
expect(deliveryParentContexts[0]?.spanId).toBe(messageSpanContext.spanId);
expect(deliveryParentContexts[1]?.traceId).toBe(TRACE_ID);
expect(deliveryParentContexts[1]?.spanId).toBe(messageSpanContext.spanId);
await service.stop?.(ctx);
});
test("parents multi-batch late delivery spans from the retained message context", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageDispatchStarted({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
source: "replyResolver",
});
for (let index = 0; index < 125; index += 1) {
emitInternalDiagnosticEventForTest({
type: "message.delivery.completed",
channel: "slack",
deliveryKind: "text",
sessionKey: `agent:main:slack:channel:c${index}`,
durationMs: 15,
resultCount: 1,
});
}
logMessageProcessed({
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 120,
outcome: "completed",
});
});
const messageSpan = spanByName("openclaw.message.processed");
const messageSpanContext = messageSpan.spanContext();
expect(messageSpan.end).toHaveBeenCalledTimes(1);
await waitForDiagnosticEventsDrained();
const deliveryParentContexts = startedSpanParentContexts("openclaw.message.delivery");
expect(deliveryParentContexts).toHaveLength(125);
expect(deliveryParentContexts.every((parent) => parent?.traceId === TRACE_ID)).toBe(true);
expect(
deliveryParentContexts.every((parent) => parent?.spanId === messageSpanContext.spanId),
).toBe(true);
await service.stop?.(ctx);
});
test("correlates skipped duplicate message lifecycle helpers to the active inbound trace", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
const messageTrace = createDiagnosticTraceContext({
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
});
runWithDiagnosticTraceContext(messageTrace, () => {
logMessageProcessed({
channel: "slack",
messageId: "msg-duplicate",
chatId: "c1",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 5,
outcome: "skipped",
reason: "duplicate",
});
});
await flushDiagnosticEvents();
const messageSpan = spanByName("openclaw.message.processed");
const messageSpanContext = messageSpan.spanContext();
const parentContext = startedSpanParentContexts("openclaw.message.processed")[0];
expect(messageSpanContext.traceId).toBe(TRACE_ID);
expect(parentContext?.traceId).toBe(TRACE_ID);
expect(parentContext?.spanId).toBe(SPAN_ID);
expect(firstSpanAttributes("openclaw.message.processed")["openclaw.reason"]).toBe("duplicate");
expect(messageSpan.end).toHaveBeenCalledTimes(1);
await service.stop?.(ctx);
});
test("does not force a remote parent for fallback root message processed spans", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "message.processed",
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 25,
outcome: "skipped",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
await flushDiagnosticEvents();
expect(spanByName("openclaw.message.processed").spanContext().traceId).toBe(TRACE_ID);
expect(startedSpanParentContexts("openclaw.message.processed")[0]).toBeUndefined();
await service.stop?.(ctx);
});
test("does not retain fallback message processed spans as active parents", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "message.processed",
channel: "slack",
sessionKey: "agent:main:slack:channel:c1",
durationMs: 25,
outcome: "skipped",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
expect(spanByName("openclaw.message.processed").end).toHaveBeenCalledTimes(1);
telemetryState.tracer.setSpanContext.mockClear();
emitTrustedDiagnosticEvent({
type: "harness.run.started",
runId: "run-1",
harnessId: "codex",
pluginId: "codex",
provider: "openai",
model: "gpt-5.5",
channel: "slack",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
expect(startedSpanCall("openclaw.harness.run")?.[2]).toBeUndefined();
await service.stop?.(ctx);
});
test("retains trusted run context long enough for exact post-completion usage parenting", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await Promise.resolve();
emitTrustedDiagnosticEvent({
type: "model.usage",
provider: "openai",
model: "gpt-5.4",
usage: { input: 3, output: 2, total: 5 },
durationMs: 10,
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
await flushDiagnosticEvents();
const runSpan = telemetryState.spans.find((span) => span.name === "openclaw.run");
const runSpanId = runSpan?.spanContext.mock.results[0]?.value?.spanId;
const modelUsageCall = telemetryState.tracer.startSpan.mock.calls.find(
(call) => call[0] === "openclaw.model.usage",
);
const linkedSpanContext = firstSetSpanContext();
expect(linkedSpanContext.traceId).toBe(TRACE_ID);
expect(linkedSpanContext.spanId).toBe(runSpanId);
expect(
(modelUsageCall?.[2] as { spanContext?: { spanId?: string } } | undefined)?.spanContext
?.spanId,
).toBe(runSpanId);
expect(firstSpanEndTime("openclaw.run")).toBeTypeOf("number");
await service.stop?.(ctx);
});
test("does not parent sibling active runs through shared upstream aliases", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-2",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
const runContexts = startedSpanParentContextsByName("openclaw.run");
expect(runContexts).toHaveLength(2);
expect(runContexts[0]?.parentContext).toBeUndefined();
expect(runContexts[1]?.parentContext).toBeUndefined();
await service.stop?.(ctx);
});
test("does not parent sibling runs through retained upstream aliases", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
@@ -2594,6 +3208,201 @@ describe("diagnostics-otel service", () => {
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-2",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
const runContexts = startedSpanParentContextsByName("openclaw.run");
expect(runContexts).toHaveLength(2);
expect(runContexts[0]?.parentContext).toBeUndefined();
expect(runContexts[1]?.parentContext).toBeUndefined();
await service.stop?.(ctx);
});
test("parents retained upstream alias events only when the owner matches", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: "call-1",
provider: "openai",
model: "gpt-5.4",
durationMs: 80,
trace: {
traceId: TRACE_ID,
spanId: MODEL_CALL_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await flushDiagnosticEvents();
const runSpanContext = spanByName("openclaw.run").spanContext();
const modelParentContext = startedSpanParentContexts("openclaw.model.call")[0];
expect(modelParentContext?.traceId).toBe(TRACE_ID);
expect(modelParentContext?.spanId).toBe(runSpanContext.spanId);
await service.stop?.(ctx);
});
test("parents multi-batch late model spans from the retained run context", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
for (let index = 0; index < 125; index += 1) {
emitTrustedDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: `call-${index}`,
provider: "openai",
model: "gpt-5.4",
durationMs: 80,
trace: {
traceId: TRACE_ID,
spanId: numberedSpanId(index),
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
}
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
const runSpan = spanByName("openclaw.run");
const runSpanContext = runSpan.spanContext();
expect(runSpan.end).toHaveBeenCalledTimes(1);
await waitForDiagnosticEventsDrained();
const modelParentContexts = startedSpanParentContexts("openclaw.model.call");
expect(modelParentContexts).toHaveLength(125);
expect(modelParentContexts.every((parent) => parent?.traceId === TRACE_ID)).toBe(true);
expect(modelParentContexts.every((parent) => parent?.spanId === runSpanContext.spanId)).toBe(
true,
);
await service.stop?.(ctx);
});
test("removes retained run contexts after queued diagnostics drain", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
for (let index = 0; index < 125; index += 1) {
emitTrustedDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: `call-${index}`,
provider: "openai",
model: "gpt-5.4",
durationMs: 80,
trace: {
traceId: TRACE_ID,
spanId: numberedSpanId(index),
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
}
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await waitForDiagnosticEventsDrained();
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
await waitForDiagnosticEventsDrained();
await Promise.resolve();
telemetryState.tracer.setSpanContext.mockClear();
telemetryState.tracer.startSpan.mockClear();
emitTrustedDiagnosticEvent({
type: "model.usage",
provider: "openai",
@@ -2603,26 +3412,69 @@ describe("diagnostics-otel service", () => {
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
expect(startedSpanCall("openclaw.model.usage")?.[2]).toBeUndefined();
await service.stop?.(ctx);
});
test("clears retained run contexts when the service stops", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitTrustedDiagnosticEvent({
type: "run.started",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
emitTrustedDiagnosticEvent({
type: "run.completed",
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
outcome: "completed",
durationMs: 100,
trace: {
traceId: TRACE_ID,
spanId: CHILD_SPAN_ID,
parentSpanId: SPAN_ID,
traceFlags: "01",
},
});
await flushDiagnosticEvents();
const runSpan = telemetryState.spans.find((span) => span.name === "openclaw.run");
const runSpanId = runSpan?.spanContext.mock.results[0]?.value?.spanId;
const modelUsageCall = telemetryState.tracer.startSpan.mock.calls.find(
(call) => call[0] === "openclaw.model.usage",
);
await service.stop?.(ctx);
await service.start(ctx);
telemetryState.tracer.setSpanContext.mockClear();
telemetryState.tracer.startSpan.mockClear();
const linkedSpanContext = firstSetSpanContext();
expect(linkedSpanContext.traceId).toBe(TRACE_ID);
expect(linkedSpanContext.spanId).toBe(runSpanId);
expect(
(modelUsageCall?.[2] as { spanContext?: { spanId?: string } } | undefined)?.spanContext
?.spanId,
).toBe(runSpanId);
expect(firstSpanEndTime("openclaw.run")).toBeTypeOf("number");
emitTrustedDiagnosticEvent({
type: "model.usage",
provider: "openai",
model: "gpt-5.4",
usage: { input: 3, output: 2, total: 5 },
durationMs: 10,
trace: {
traceId: TRACE_ID,
spanId: GRANDCHILD_SPAN_ID,
parentSpanId: CHILD_SPAN_ID,
traceFlags: "01",
},
});
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
expect(startedSpanCall("openclaw.model.usage")?.[2]).toBeUndefined();
await service.stop?.(ctx);
});

View File

@@ -6,6 +6,7 @@ import {
SpanStatusCode,
TraceFlags,
} from "@opentelemetry/api";
import type { SpanContext } from "@opentelemetry/api";
import type { LogRecord, SeverityNumber } from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
@@ -26,6 +27,7 @@ import {
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS,
ATTR_GEN_AI_TOOL_DEFINITIONS,
} from "@opentelemetry/semantic-conventions/incubating";
import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-runtime";
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
import type {
DiagnosticEventMetadata,
@@ -86,6 +88,8 @@ const GEN_AI_TOKEN_USAGE_BUCKETS = [
const GEN_AI_OPERATION_DURATION_BUCKETS = [
0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92,
];
const MAX_RETAINED_TRUSTED_SPAN_CONTEXTS = 1024;
const RETAINED_TRUSTED_SPAN_CONTEXT_TIMEOUT_MS = 5_000;
type OtelContentCapturePolicy = {
inputMessages: boolean;
@@ -128,6 +132,7 @@ type SessionRecoveryDiagnosticEvent = Extract<
{ type: "session.recovery.requested" | "session.recovery.completed" }
>;
type TalkDiagnosticEvent = Extract<DiagnosticEventPayload, { type: "talk.event" }>;
type TrustedSpanAliasOwner = { kind: "run"; id: string };
const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = {
inputMessages: false,
@@ -1240,17 +1245,25 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const meter = metrics.getMeter("openclaw");
const tracer = trace.getTracer("openclaw");
const activeTrustedSpans = new Map<string, ReturnType<typeof tracer.startSpan>>();
const activeTrustedSpanAliases = new Map<string, ReturnType<typeof tracer.startSpan>>();
const pendingTrustedRunFinalizers = new Map<string, ReturnType<typeof setImmediate>>();
const activeTrustedSpanAliases = new Map<
string,
{ span: ReturnType<typeof tracer.startSpan>; spanId: string; owner: TrustedSpanAliasOwner }
>();
const retainedTrustedSpanContexts = new Map<
string,
{ spanContext: SpanContext; token: symbol; owner?: TrustedSpanAliasOwner }
>();
const retainedTrustedSpanContextCleanupTimers = new Set<ReturnType<typeof setTimeout>>();
stopActiveTrustedSpans = () => {
const stopAt = Date.now();
for (const handle of pendingTrustedRunFinalizers.values()) {
clearImmediate(handle);
for (const handle of retainedTrustedSpanContextCleanupTimers) {
clearTimeout(handle);
}
pendingTrustedRunFinalizers.clear();
retainedTrustedSpanContextCleanupTimers.clear();
retainedTrustedSpanContexts.clear();
for (const span of new Set([
...activeTrustedSpans.values(),
...activeTrustedSpanAliases.values(),
...Array.from(activeTrustedSpanAliases.values(), (entry) => entry.span),
])) {
span.end(stopAt);
}
@@ -1679,20 +1692,139 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => (metadata.trusted ? normalizeTraceContext(evt.trace) : undefined);
const internalOrTrustedTraceContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => (metadata.trusted || metadata.internal ? normalizeTraceContext(evt.trace) : undefined);
const trustedSpanAliasOwner = (
evt: DiagnosticEventPayload,
): TrustedSpanAliasOwner | undefined => {
if ("runId" in evt && evt.runId) {
return { kind: "run", id: evt.runId };
}
return undefined;
};
const sameTrustedSpanAliasOwner = (
left: TrustedSpanAliasOwner | undefined,
right: TrustedSpanAliasOwner | undefined,
) => Boolean(left && right && left.kind === right.kind && left.id === right.id);
const trustedSpanAliasKey = (spanId: string, owner: TrustedSpanAliasOwner) =>
`${spanId}:${owner.kind}:${owner.id}`;
const retainedTrustedSpanContextKey = (
traceId: string,
spanId: string,
owner?: TrustedSpanAliasOwner,
) => `${traceId}:${owner ? trustedSpanAliasKey(spanId, owner) : spanId}`;
const retainedTrustedSpanContext = (
traceContext: DiagnosticTraceContext | undefined,
spanId: string | undefined,
owner?: TrustedSpanAliasOwner,
) => {
if (!traceContext?.traceId || !spanId) {
return undefined;
}
const retained =
(owner
? retainedTrustedSpanContexts.get(
retainedTrustedSpanContextKey(traceContext.traceId, spanId, owner),
)
: undefined) ??
retainedTrustedSpanContexts.get(
retainedTrustedSpanContextKey(traceContext.traceId, spanId),
);
if (retained?.spanContext.traceId !== traceContext.traceId) {
return undefined;
}
if (retained.owner && !sameTrustedSpanAliasOwner(retained.owner, owner)) {
return undefined;
}
return retained.spanContext;
};
const activeTrustedSpanAlias = (spanId: string, owner: TrustedSpanAliasOwner | undefined) => {
if (!owner) {
return undefined;
}
const alias = activeTrustedSpanAliases.get(trustedSpanAliasKey(spanId, owner));
if (!alias || !sameTrustedSpanAliasOwner(alias.owner, owner)) {
return undefined;
}
return alias.span;
};
const internalOrTrustedParentContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const traceContext = internalOrTrustedTraceContext(evt, metadata);
const parentSpanId = traceContext?.parentSpanId ?? traceContext?.spanId;
if (!traceContext || !parentSpanId) {
return undefined;
}
return contextForTraceContext({
...traceContext,
spanId: parentSpanId,
});
};
const internalOrTrustedExplicitParentContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const traceContext = internalOrTrustedTraceContext(evt, metadata);
if (!traceContext?.parentSpanId) {
return undefined;
}
return contextForTraceContext({
...traceContext,
spanId: traceContext.parentSpanId,
});
};
const activeTrustedParentContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const parentSpanId = trustedTraceContext(evt, metadata)?.parentSpanId;
const traceContext = trustedTraceContext(evt, metadata);
const parentSpanId = traceContext?.parentSpanId;
if (!parentSpanId) {
return undefined;
}
const owner = trustedSpanAliasOwner(evt);
const activeParentSpan =
activeTrustedSpans.get(parentSpanId) ?? activeTrustedSpanAliases.get(parentSpanId);
if (!activeParentSpan) {
activeTrustedSpans.get(parentSpanId) ?? activeTrustedSpanAlias(parentSpanId, owner);
const spanContext =
activeParentSpan?.spanContext() ??
retainedTrustedSpanContext(traceContext, parentSpanId, owner);
if (!spanContext) {
return undefined;
}
return trace.setSpanContext(otelContextApi.active(), activeParentSpan.spanContext());
return trace.setSpanContext(otelContextApi.active(), spanContext);
};
const activeInternalOrTrustedContext = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const traceContext = internalOrTrustedTraceContext(evt, metadata);
if (!traceContext) {
return undefined;
}
const owner = trustedSpanAliasOwner(evt);
const activeSpan =
(traceContext.spanId
? (activeTrustedSpans.get(traceContext.spanId) ??
activeTrustedSpanAlias(traceContext.spanId, owner))
: undefined) ??
(traceContext.parentSpanId
? (activeTrustedSpans.get(traceContext.parentSpanId) ??
activeTrustedSpanAlias(traceContext.parentSpanId, owner))
: undefined);
if (activeSpan) {
return trace.setSpanContext(otelContextApi.active(), activeSpan.spanContext());
}
const retainedSpanContext =
retainedTrustedSpanContext(traceContext, traceContext.spanId, owner) ??
retainedTrustedSpanContext(traceContext, traceContext.parentSpanId, owner);
if (retainedSpanContext) {
return trace.setSpanContext(otelContextApi.active(), retainedSpanContext);
}
return internalOrTrustedParentContext(evt, metadata);
};
const trackTrustedSpan = (
evt: DiagnosticEventPayload,
@@ -1705,6 +1837,17 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
return span;
};
const trackInternalOrTrustedSpan = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
span: ReturnType<typeof tracer.startSpan>,
) => {
const spanId = internalOrTrustedTraceContext(evt, metadata)?.spanId;
if (spanId) {
activeTrustedSpans.set(spanId, span);
}
return span;
};
const takeTrackedTrustedSpan = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
@@ -1719,33 +1862,109 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
return span;
};
const getTrackedInternalOrTrustedSpan = (
evt: DiagnosticEventPayload,
metadata: DiagnosticEventMetadata,
) => {
const spanId = internalOrTrustedTraceContext(evt, metadata)?.spanId;
if (!spanId) {
return undefined;
}
return activeTrustedSpans.get(spanId);
};
const setSpanAttrs = (
span: ReturnType<typeof tracer.startSpan>,
attributes: Record<string, string | number | boolean>,
) => {
span.setAttributes?.(redactOtelAttributes(attributes));
};
const scheduleTrackedRunSpanFinalize = (
const retainTrustedSpanContext = (
traceId: string,
spanId: string,
spanContext: SpanContext,
token: symbol,
owner?: TrustedSpanAliasOwner,
) => {
retainedTrustedSpanContexts.set(retainedTrustedSpanContextKey(traceId, spanId, owner), {
spanContext,
token,
...(owner ? { owner } : {}),
});
while (retainedTrustedSpanContexts.size > MAX_RETAINED_TRUSTED_SPAN_CONTEXTS) {
const oldestKey = retainedTrustedSpanContexts.keys().next().value;
if (!oldestKey) {
break;
}
retainedTrustedSpanContexts.delete(oldestKey);
}
};
const scheduleRetainedTrustedSpanContextCleanup = (token: symbol) => {
let drainHandle: ReturnType<typeof setTimeout> | undefined;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (drainHandle) {
clearTimeout(drainHandle);
retainedTrustedSpanContextCleanupTimers.delete(drainHandle);
drainHandle = undefined;
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
retainedTrustedSpanContextCleanupTimers.delete(timeoutHandle);
timeoutHandle = undefined;
}
for (const [key, retained] of retainedTrustedSpanContexts) {
if (retained.token === token) {
retainedTrustedSpanContexts.delete(key);
}
}
};
drainHandle = setTimeout(() => {
if (drainHandle) {
retainedTrustedSpanContextCleanupTimers.delete(drainHandle);
drainHandle = undefined;
}
void waitForDiagnosticEventsDrained().then(cleanup, cleanup);
}, 0);
(drainHandle as { unref?: () => void }).unref?.();
retainedTrustedSpanContextCleanupTimers.add(drainHandle);
timeoutHandle = setTimeout(cleanup, RETAINED_TRUSTED_SPAN_CONTEXT_TIMEOUT_MS);
(timeoutHandle as { unref?: () => void }).unref?.();
retainedTrustedSpanContextCleanupTimers.add(timeoutHandle);
};
const completeTrackedLifecycleSpan = (
spanId: string,
parentSpanId: string | undefined,
span: ReturnType<typeof tracer.startSpan>,
endTimeMs: number,
) => {
const existingHandle = pendingTrustedRunFinalizers.get(spanId);
if (existingHandle) {
clearImmediate(existingHandle);
const spanContext = span.spanContext();
const retainedKeys: Array<{ spanId: string; owner?: TrustedSpanAliasOwner }> = [{ spanId }];
const retainedAliasKeys: string[] = [];
for (const [aliasKey, alias] of activeTrustedSpanAliases) {
if (alias.span === span) {
retainedKeys.push({ spanId: alias.spanId, owner: alias.owner });
retainedAliasKeys.push(aliasKey);
}
}
const handle = setImmediate(() => {
pendingTrustedRunFinalizers.delete(spanId);
if (activeTrustedSpans.get(spanId) === span) {
activeTrustedSpans.delete(spanId);
if (activeTrustedSpans.get(spanId) === span) {
activeTrustedSpans.delete(spanId);
}
for (const aliasKey of retainedAliasKeys) {
if (activeTrustedSpanAliases.get(aliasKey)?.span === span) {
activeTrustedSpanAliases.delete(aliasKey);
}
if (parentSpanId && activeTrustedSpanAliases.get(parentSpanId) === span) {
activeTrustedSpanAliases.delete(parentSpanId);
}
span.end(endTimeMs);
});
pendingTrustedRunFinalizers.set(spanId, handle);
}
span.end(endTimeMs);
const token = Symbol("retainedTrustedSpanContext");
for (const retainedKey of retainedKeys) {
retainTrustedSpanContext(
spanContext.traceId,
retainedKey.spanId,
spanContext,
token,
retainedKey.owner,
);
}
scheduleRetainedTrustedSpanContextCleanup(token);
};
const addRunAttrs = (
@@ -1962,11 +2181,28 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const recordMessageDispatchStarted = (
evt: Extract<DiagnosticEventPayload, { type: "message.dispatch.started" }>,
metadata: DiagnosticEventMetadata,
) => {
messageDispatchStartedCounter.add(1, {
const attrs = {
"openclaw.channel": lowCardinalityAttr(evt.channel),
"openclaw.source": lowCardinalityAttr(evt.source),
});
};
messageDispatchStartedCounter.add(1, attrs);
if (!tracesEnabled) {
return;
}
const traceContext = internalOrTrustedTraceContext(evt, metadata);
if (!traceContext?.spanId || activeTrustedSpans.has(traceContext.spanId)) {
return;
}
trackInternalOrTrustedSpan(
evt,
metadata,
spanWithDuration("openclaw.message.processed", attrs, undefined, {
parentContext: internalOrTrustedExplicitParentContext(evt, metadata),
startTimeMs: evt.ts,
}),
);
};
const recordMessageDispatchCompleted = (
@@ -1984,6 +2220,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const recordMessageProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
metadata: DiagnosticEventMetadata,
) => {
const attrs = {
"openclaw.channel": lowCardinalityAttr(evt.channel),
@@ -2000,11 +2237,23 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.reason) {
spanAttrs["openclaw.reason"] = lowCardinalityAttr(evt.reason, "unknown");
}
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
const trackedSpan = getTrackedInternalOrTrustedSpan(evt, metadata);
const span =
trackedSpan ??
spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs, {
parentContext: internalOrTrustedExplicitParentContext(evt, metadata),
endTimeMs: evt.ts,
});
setSpanAttrs(span, spanAttrs);
if (evt.outcome === "error" && evt.error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) });
}
span.end();
const traceContext = internalOrTrustedTraceContext(evt, metadata);
if (trackedSpan && traceContext?.spanId) {
completeTrackedLifecycleSpan(traceContext.spanId, trackedSpan, evt.ts);
return;
}
span.end(evt.ts);
};
const messageDeliveryAttrs = (
@@ -2022,6 +2271,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const recordMessageDeliveryCompleted = (
evt: Extract<DiagnosticEventPayload, { type: "message.delivery.completed" }>,
metadata: DiagnosticEventMetadata,
) => {
const attrs = {
...messageDeliveryAttrs(evt),
@@ -2038,13 +2288,14 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
"openclaw.delivery.result_count": evt.resultCount,
},
evt.durationMs,
{ endTimeMs: evt.ts },
{ parentContext: activeInternalOrTrustedContext(evt, metadata), endTimeMs: evt.ts },
);
span.end(evt.ts);
};
const recordMessageDeliveryError = (
evt: Extract<DiagnosticEventPayload, { type: "message.delivery.error" }>,
metadata: DiagnosticEventMetadata,
) => {
const attrs = {
...messageDeliveryAttrs(evt),
@@ -2056,6 +2307,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return;
}
const span = spanWithDuration("openclaw.message.delivery", attrs, evt.durationMs, {
parentContext: activeInternalOrTrustedContext(evt, metadata),
endTimeMs: evt.ts,
});
span.setStatus({
@@ -2084,7 +2336,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
);
const parentSpanId = trustedTraceContext(evt, metadata)?.parentSpanId;
if (parentSpanId && !activeTrustedSpans.has(parentSpanId)) {
activeTrustedSpanAliases.set(parentSpanId, span);
const owner: TrustedSpanAliasOwner = { kind: "run", id: evt.runId };
activeTrustedSpanAliases.set(trustedSpanAliasKey(parentSpanId, owner), {
span,
spanId: parentSpanId,
owner,
});
}
};
@@ -2363,12 +2620,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
});
}
if (trackedSpan && trustedTrace?.spanId) {
scheduleTrackedRunSpanFinalize(
trustedTrace.spanId,
trustedTrace.parentSpanId,
trackedSpan,
evt.ts,
);
completeTrackedLifecycleSpan(trustedTrace.spanId, trackedSpan, evt.ts);
return;
}
span.end(evt.ts);
@@ -2428,8 +2680,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
spanAttrs["openclaw.harness.items.completed"] = evt.itemLifecycle.completedCount;
spanAttrs["openclaw.harness.items.active"] = evt.itemLifecycle.activeCount;
}
const trustedTrace = trustedTraceContext(evt, metadata);
const trackedSpan = trustedTrace?.spanId
? activeTrustedSpans.get(trustedTrace.spanId)
: undefined;
const span =
takeTrackedTrustedSpan(evt, metadata) ??
trackedSpan ??
spanWithDuration("openclaw.harness.run", spanAttrs, evt.durationMs, {
parentContext: activeTrustedParentContext(evt, metadata),
endTimeMs: evt.ts,
@@ -2441,6 +2697,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
message: "error",
});
}
if (trackedSpan && trustedTrace?.spanId) {
completeTrackedLifecycleSpan(trustedTrace.spanId, trackedSpan, evt.ts);
return;
}
span.end(evt.ts);
};
@@ -3076,22 +3336,22 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
recordMessageReceived(evt);
return;
case "message.dispatch.started":
recordMessageDispatchStarted(evt);
recordMessageDispatchStarted(evt, metadata);
return;
case "message.dispatch.completed":
recordMessageDispatchCompleted(evt);
return;
case "message.processed":
recordMessageProcessed(evt);
recordMessageProcessed(evt, metadata);
return;
case "message.delivery.started":
recordMessageDeliveryStarted(evt);
return;
case "message.delivery.completed":
recordMessageDeliveryCompleted(evt);
recordMessageDeliveryCompleted(evt, metadata);
return;
case "message.delivery.error":
recordMessageDeliveryError(evt);
recordMessageDeliveryError(evt, metadata);
return;
case "talk.event":
recordTalkEvent(evt, metadata);

View File

@@ -1,4 +1,4 @@
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
@@ -6,6 +6,7 @@ import {
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingProgressCommentary,
@@ -281,6 +282,13 @@ export function createDiscordDraftPreviewController(params: {
if (!normalized) {
return;
}
const displayLine = formatReasoningProgressDisplayLine(
normalized,
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
);
if (!displayLine) {
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
const priorIndex =
lastReasoningProgressLine === undefined
@@ -288,13 +296,13 @@ export function createDiscordDraftPreviewController(params: {
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
if (priorIndex >= 0) {
previewToolProgressLines = [...previewToolProgressLines];
previewToolProgressLines[priorIndex] = normalized;
previewToolProgressLines[priorIndex] = displayLine;
} else {
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
}
lastReasoningProgressLine = normalized;
lastReasoningProgressLine = displayLine;
}
const progressActive = await progressDraftGate.noteWork();
if (progressActive && progressDraftGate.hasStarted) {
@@ -465,11 +473,57 @@ export function createDiscordDraftPreviewController(params: {
function normalizeReasoningProgressLine(text: string): string {
return text
.replace(/^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i, "")
.replace(
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
"",
)
.replace(/\s+/g, " ")
.trim();
}
function normalizeReasoningProgressInput(text: string): string {
const normalized = normalizeReasoningProgressLine(text);
const italic = normalized.match(/^_(.*)_$/u);
return (italic?.[1] ?? normalized).trim();
}
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalizedText = normalizeReasoningProgressInput(text);
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
if (!formatted) {
return "";
}
if (Array.from(formatted).length <= maxChars) {
return formatted;
}
const italic = formatted.match(/^_(.*)_$/u);
if (!italic) {
return compactReasoningProgressDisplayLine(formatted, maxChars);
}
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
return body ? `_${body}_` : "";
}
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalized = text.replace(/\s+/g, " ").trim();
const chars = Array.from(normalized);
if (chars.length <= maxChars) {
return normalized;
}
if (maxChars <= 1) {
return "…";
}
const head = chars
.slice(0, maxChars - 1)
.join("")
.trimEnd();
const boundary = head.search(/\s+\S*$/u);
if (boundary > Math.floor(maxChars * 0.6)) {
return `${head.slice(0, boundary).trimEnd()}`;
}
return `${head}`;
}
function normalizeCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
@@ -512,7 +566,9 @@ function mergeReasoningProgressText(
}
function isReasoningSnapshotText(text: string): boolean {
return /^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i.test(text);
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
text,
);
}
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {

View File

@@ -3123,6 +3123,320 @@ describe("processDiscordMessage draft streaming", () => {
expect(updates.join("\n")).not.toContain("Thinking\n");
});
it("accumulates reasoning deltas in Discord progress drafts", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
for (const text of ["Considering", " plugin", " installation", "!"]) {
await params?.replyOptions?.onReasoningStream?.({ text });
}
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Considering plugin installation!_",
);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("• _!_");
});
it("preserves raw reasoning content that starts with Thinking", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking" });
await params?.replyOptions?.onReasoningStream?.({ text: " through the install plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Thinking through the install plan_",
);
});
it("preserves raw reasoning content that starts with Thinking colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking: compare install paths" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Thinking: compare install paths_",
);
});
it("preserves raw reasoning content that starts with Reasoning colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning: compare install paths" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Reasoning: compare install paths_",
);
});
it("strips legacy Reasoning newline wrappers from progress snapshots", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Reasoning:\ncompare install paths",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _compare install paths_",
);
});
it("strips legacy Thinking ellipsis display wrappers from progress snapshots", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Thinking...\n\n_compare install paths_",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _compare install paths_",
);
});
it("preserves raw reasoning content that starts with a Thinking line", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking\nthrough the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Thinking through the plan_",
);
});
it("appends raw reasoning chunks that start with Thinking", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking about the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _I was Thinking about the plan_",
);
});
it("appends raw reasoning chunks that start with Thinking ellipsis", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking... through the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _I was Thinking... through the plan_",
);
});
it("appends raw reasoning chunks that start with Reasoning colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning: through edge cases" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _I was Reasoning: through edge cases_",
);
});
it("keeps reasoning italics balanced when progress lines truncate", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Thinking through a very detailed installation plan with many steps",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
maxLineChars: 36,
},
},
},
});
await runProcessDiscordMessage(ctx);
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
const reasoningLine = lastUpdate?.split("\n").at(-1);
expect(reasoningLine).toMatch(/^ _.*_$/u);
expect(reasoningLine?.match(/_/gu)).toHaveLength(2);
});
it("replaces reasoning snapshots instead of appending duplicates", async () => {
const draftStream = createMockDraftStreamForTest();
@@ -3152,9 +3466,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠 Exec\n• _Reading _ _Checking_",
);
expect(draftStream.update.mock.calls.at(-1)?.[0]).toContain("_Reading Checking_");
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("_Checking Reading");
});

View File

@@ -1,10 +1,6 @@
import path from "node:path";
import { MessageFlags } from "discord-api-types/v10";
import {
formatReasoningMessage,
resolveAckReaction,
resolveHumanDelayConfig,
} from "openclaw/plugin-sdk/agent-runtime";
import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
createStatusReactionController,
DEFAULT_TIMING,
@@ -987,8 +983,7 @@ async function processDiscordMessageInner(
: undefined,
onReasoningStream: async (payload) => {
await statusReactions.setThinking();
const formattedText = payload?.text ? formatReasoningMessage(payload.text) : undefined;
await draftPreview.pushReasoningProgress(formattedText, {
await draftPreview.pushReasoningProgress(payload?.text, {
snapshot: payload?.isReasoningSnapshot === true,
});
},

View File

@@ -1575,7 +1575,7 @@ export async function handleFeishuMessage(params: {
turnResult.dispatched &&
shouldSendNoVisibleReplyFallback({
...turnResult.dispatchResult,
failedCounts: dispatcher.getFailedCounts(),
failedCounts: dispatcher.getFailedCounts?.() ?? { tool: 0, block: 0, final: 0 },
})
) {
await ensureNoVisibleReplyFallback("broadcast-dispatch-complete-no-visible-reply");
@@ -1771,7 +1771,7 @@ export async function handleFeishuMessage(params: {
if (
shouldSendNoVisibleReplyFallback({
...dispatchResult,
failedCounts: dispatcher.getFailedCounts(),
failedCounts: dispatcher.getFailedCounts?.() ?? { tool: 0, block: 0, final: 0 },
})
) {
await ensureNoVisibleReplyFallback("dispatch-complete-no-visible-reply");

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { resolveThinkingProfile } from "./provider-policy-api.js";
describe("github-copilot provider-policy-api", () => {
it("returns the base level set for non-xhigh GitHub Copilot models", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4.6",
})?.levels.map((level) => level.id),
).toEqual(["off", "minimal", "low", "medium", "high"]);
});
it("appends xhigh for current static GPT Copilot xhigh ids", () => {
for (const modelId of ["gpt-5.4", "gpt-5.3-codex"]) {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId,
})?.levels.map((level) => level.id),
`model=${modelId}`,
).toContain("xhigh");
}
});
it("appends xhigh when catalog compat advertises it", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "future-copilot-model",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("appends xhigh for static Copilot metadata overrides", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "claude-opus-4.7-1m-internal",
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("normalizes the model id casing before xhigh membership checks", () => {
expect(
resolveThinkingProfile({
provider: "github-copilot",
modelId: "GPT-5.4",
})?.levels.map((level) => level.id),
).toContain("xhigh");
});
it("returns null for non-GitHub Copilot providers", () => {
expect(
resolveThinkingProfile({
provider: "openai",
modelId: "gpt-5.4",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,39 @@
import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveStaticCopilotModelOverride } from "./model-metadata.js";
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.3-codex"] as const;
function compatSupportsXHigh(
compat: { supportedReasoningEfforts?: readonly string[] | null } | null | undefined,
) {
return (
Array.isArray(compat?.supportedReasoningEfforts) &&
compat.supportedReasoningEfforts.some(
(effort) => normalizeOptionalLowercaseString(effort) === "xhigh",
)
);
}
export function resolveThinkingProfile(context: ProviderDefaultThinkingPolicyContext) {
if (context.provider.trim().toLowerCase() !== "github-copilot") {
return null;
}
const normalizedModelId = normalizeOptionalLowercaseString(context.modelId) ?? "";
const staticCompat = resolveStaticCopilotModelOverride(normalizedModelId)?.compat;
const modelSupportsXHigh =
COPILOT_XHIGH_MODEL_IDS.includes(normalizedModelId as never) ||
compatSupportsXHigh(context.compat) ||
compatSupportsXHigh(staticCompat);
return {
levels: [
{ id: "off" as const },
{ id: "minimal" as const },
{ id: "low" as const },
{ id: "medium" as const },
{ id: "high" as const },
...(modelSupportsXHigh ? [{ id: "xhigh" as const }] : []),
],
};
}

View File

@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
isGoogleGenerativeAiApi,
isGoogleVertexBaseUrl,
isGoogleVertexHostname,
normalizeGoogleApiBaseUrl,
normalizeGoogleGenerativeAiBaseUrl,
normalizeGoogleProviderConfig,
@@ -83,6 +85,23 @@ describe("google generative ai helpers", () => {
models: [{ api: "openai-completions" }],
}),
).toBe(false);
expect(
shouldNormalizeGoogleGenerativeAiProviderConfig("google-vertex", {
baseUrl: "https://aiplatform.googleapis.com",
}),
).toBe(false);
});
it("detects native Google Vertex hosts by hostname only", () => {
expect(isGoogleVertexHostname("aiplatform.googleapis.com")).toBe(true);
expect(isGoogleVertexHostname("us-central1-aiplatform.googleapis.com")).toBe(true);
expect(isGoogleVertexHostname("generativelanguage.googleapis.com")).toBe(false);
expect(isGoogleVertexHostname("evil-aiplatform.googleapis.com.attacker.com")).toBe(false);
expect(
isGoogleVertexBaseUrl(
"https://generativelanguage.googleapis.com/v1beta/proxy/aiplatform.googleapis.com",
),
).toBe(false);
});
it("normalizes transport baseUrls only for Google Generative AI", () => {
@@ -114,6 +133,28 @@ describe("google generative ai helpers", () => {
api: "openai-completions",
baseUrl: "https://generativelanguage.googleapis.com",
});
expect(
resolveGoogleGenerativeAiTransport({
provider: "google-vertex",
api: undefined,
baseUrl: "https://us-central1-aiplatform.googleapis.com",
}),
).toEqual({
api: "google-vertex",
baseUrl: "https://us-central1-aiplatform.googleapis.com",
});
expect(
resolveGoogleGenerativeAiTransport({
provider: "google-vertex",
api: "openai-completions",
baseUrl:
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
}),
).toEqual({
api: "openai-completions",
baseUrl:
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
});
});
it("normalizes google-vertex model ids without rewriting the OpenAI-compatible baseUrl", () => {

View File

@@ -30,6 +30,8 @@ export {
export {
DEFAULT_GOOGLE_API_BASE_URL,
isGoogleGenerativeAiApi,
isGoogleVertexBaseUrl,
isGoogleVertexHostname,
normalizeGoogleApiBaseUrl,
normalizeGoogleGenerativeAiBaseUrl,
normalizeGoogleProviderConfig,

View File

@@ -40,4 +40,9 @@ describe("google model id helpers", () => {
expect(normalizeGoogleModelId("gemini-3.1-flash-lite")).toBe("gemini-3.1-flash-lite");
expect(normalizeGoogleModelId("gemini-3.1-flash-lite-preview")).toBe("gemini-3.1-flash-lite");
});
it("maps the old Gemma 4 26B shorthand to Google's canonical API id", () => {
expect(normalizeGoogleModelId("gemma-4-26b")).toBe("gemma-4-26b-a4b-it");
expect(normalizeGoogleModelId("google/gemma-4-26b")).toBe("google/gemma-4-26b-a4b-it");
});
});

View File

@@ -27,6 +27,9 @@ export function normalizeGoogleModelId(id: string): string {
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
return "gemini-3-flash-preview";
}
if (id === "gemma-4-26b") {
return "gemma-4-26b-a4b-it";
}
return id;
}

View File

@@ -11,8 +11,13 @@ describe("google provider catalog", () => {
expect(provider.api).toBe("google-vertex");
expect(provider.baseUrl).toBe("https://{location}-aiplatform.googleapis.com");
expect(provider.models.map((model) => model.id)).toEqual(
expect.arrayContaining(["gemini-2.5-pro", "gemini-3.1-pro-preview"]),
expect.arrayContaining(["gemini-2.5-pro", "gemini-3.1-pro-preview", "gemini-3.1-flash-lite"]),
);
expect(provider.models.find((model) => model.id === "gemini-3.1-flash-lite")).toMatchObject({
contextWindow: 1_048_576,
maxTokens: 65_536,
reasoning: true,
});
});
it("keeps Google AI Studio and Vertex model ids aligned", () => {

View File

@@ -43,6 +43,15 @@ const GOOGLE_GEMINI_TEXT_MODELS: ModelDefinitionConfig[] = [
contextWindow: 1_048_576,
maxTokens: 65_536,
},
{
id: "gemini-3.1-flash-lite",
name: "Gemini 3.1 Flash Lite",
reasoning: true,
input: ["text", "image"],
cost: GOOGLE_GEMINI_COST,
contextWindow: 1_048_576,
maxTokens: 65_536,
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview",

View File

@@ -494,6 +494,24 @@ describe("resolveGoogleGeminiForwardCompatModel", () => {
});
});
it("canonicalizes Gemma 4 26B shorthand before cloning templates", () => {
const model = resolveGoogleGeminiForwardCompatModel({
providerId: "google",
ctx: createContext({
provider: "google",
modelId: "gemma-4-26b",
models: [createTemplateModel("google", "gemini-3-flash-preview", { reasoning: false })],
}),
});
expectModelFields(model, {
provider: "google",
id: "gemma-4-26b-a4b-it",
api: "google-generative-ai",
reasoning: true,
});
});
it("preserves template reasoning for non-Gemma 4 gemma models", () => {
const model = resolveGoogleGeminiForwardCompatModel({
providerId: "google",

View File

@@ -4,6 +4,7 @@ import type {
} from "openclaw/plugin-sdk/plugin-entry";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeGoogleModelId } from "./model-id.js";
const GOOGLE_GEMINI_CLI_PROVIDER_ID = "google-gemini-cli";
const GOOGLE_ANTIGRAVITY_PROVIDER_ID = "google-antigravity";
@@ -41,6 +42,9 @@ function normalizeGeminiProRequestId(id: string): string {
if (id === "gemini-3-pro" || id === "gemini-3-pro-preview" || id === "gemini-3.1-pro") {
return "gemini-3.1-pro-preview";
}
if (id === "gemma-4-26b") {
return normalizeGoogleModelId(id);
}
return id;
}

View File

@@ -12,6 +12,7 @@ type GoogleApiCarrier = {
};
type GoogleProviderConfigLike = GoogleApiCarrier & {
baseUrl?: string | null;
models?: ReadonlyArray<GoogleApiCarrier | null | undefined> | null;
};
@@ -37,6 +38,28 @@ function stripUrlUserInfo(url: URL): void {
url.password = "";
}
const GOOGLE_VERTEX_HOST = "aiplatform.googleapis.com";
const GOOGLE_VERTEX_REGION_HOST_SUFFIX = "-aiplatform.googleapis.com";
export function isGoogleVertexHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase();
return (
normalized === GOOGLE_VERTEX_HOST || normalized.endsWith(GOOGLE_VERTEX_REGION_HOST_SUFFIX)
);
}
export function isGoogleVertexBaseUrl(baseUrl?: string | null): boolean {
const raw = normalizeOptionalString(baseUrl);
if (!raw) {
return false;
}
try {
return isGoogleVertexHostname(new URL(raw).hostname);
} catch {
return false;
}
}
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL);
try {
@@ -85,9 +108,12 @@ export function resolveGoogleGenerativeAiTransport<TApi extends string | null |
provider?: string;
api: TApi;
baseUrl?: string;
}): { api: TApi | "google-generative-ai"; baseUrl?: string } {
}): { api: TApi | "google-generative-ai" | "google-vertex"; baseUrl?: string } {
const api =
params.api ??
(params.provider === "google-vertex" && isGoogleVertexBaseUrl(params.baseUrl)
? "google-vertex"
: undefined) ??
(params.provider === "google" && params.baseUrl ? "google-generative-ai" : params.api);
return {
api,
@@ -107,6 +133,9 @@ export function shouldNormalizeGoogleGenerativeAiProviderConfig(
providerKey: string,
provider: GoogleProviderConfigLike,
): boolean {
if (providerKey === "google-vertex" && isGoogleVertexBaseUrl(provider.baseUrl)) {
return false;
}
if (isGoogleGenerativeAiApi(provider.api)) {
return true;
}

View File

@@ -0,0 +1,67 @@
import type { Model } from "openclaw/plugin-sdk/llm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildGoogleProvider } from "./provider-registration.js";
const streamFns = vi.hoisted(() => ({
createGenerativeAi: vi.fn(() => vi.fn()),
createVertex: vi.fn(() => vi.fn()),
}));
vi.mock("./transport-stream.js", () => ({
createGoogleGenerativeAiTransportStreamFn: streamFns.createGenerativeAi,
createGoogleVertexTransportStreamFn: streamFns.createVertex,
}));
function model(overrides: Partial<Model> = {}): Model {
return {
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash",
provider: "google-vertex",
api: "google-generative-ai",
baseUrl: "https://aiplatform.googleapis.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 65_536,
...overrides,
} as Model;
}
describe("buildGoogleProvider createStreamFn", () => {
beforeEach(() => {
streamFns.createGenerativeAi.mockClear();
streamFns.createVertex.mockClear();
});
it("routes native Vertex hosts through the Vertex transport", () => {
const provider = buildGoogleProvider();
provider.createStreamFn?.({
provider: "google-vertex",
modelId: "gemini-2.5-flash",
model: model(),
} as never);
expect(streamFns.createVertex).toHaveBeenCalledTimes(1);
expect(streamFns.createGenerativeAi).not.toHaveBeenCalled();
});
it("preserves explicit OpenAI-compatible Vertex endpoint configs", () => {
const provider = buildGoogleProvider();
const result = provider.createStreamFn?.({
provider: "google-vertex",
modelId: "gemini-2.5-flash",
model: model({
api: "openai-completions",
baseUrl:
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
}),
} as never);
expect(result).toBeUndefined();
expect(streamFns.createVertex).not.toHaveBeenCalled();
expect(streamFns.createGenerativeAi).not.toHaveBeenCalled();
});
});

View File

@@ -10,6 +10,7 @@ import {
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
import {
isGoogleVertexBaseUrl,
normalizeGoogleProviderConfig,
resolveGoogleGenerativeAiTransport,
} from "./provider-policy.js";
@@ -67,12 +68,16 @@ export function buildGoogleProvider(): ProviderPlugin {
ctx,
}),
createStreamFn: ({ model }) => {
if (
model.api === "google-vertex" ||
(model.api === "google-generative-ai" &&
(model.provider === "google-vertex" || isGoogleVertexBaseUrl(model.baseUrl)))
) {
return createGoogleVertexTransportStreamFn();
}
if (model.api === "google-generative-ai") {
return createGoogleGenerativeAiTransportStreamFn();
}
if (model.api === "google-vertex") {
return createGoogleVertexTransportStreamFn();
}
return undefined;
},
...GOOGLE_GEMINI_PROVIDER_HOOKS,

View File

@@ -71,6 +71,28 @@ type MemoryManagerPurpose = Parameters<typeof getMemorySearchManager>[0]["purpos
type MemorySourceName = "memory" | "sessions";
function formatMemoryIndexIdentityWarning(
status: ReturnType<MemoryManager["status"]>,
agentId: string,
): {
reason: string;
fix: string;
} | null {
const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity);
const reason =
(indexIdentity?.status === "mismatched" || indexIdentity?.status === "missing") &&
typeof indexIdentity.reason === "string"
? indexIdentity.reason
: undefined;
if (!reason) {
return null;
}
return {
reason,
fix: `Run: openclaw memory status --index --agent ${agentId}`,
};
}
type SourceScan = {
source: MemorySourceName;
totalFiles: number | null;
@@ -868,6 +890,12 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
}
}
const identityWarning = formatMemoryIndexIdentityWarning(status, agentId);
if (identityWarning) {
lines.push(`${label("Index identity")} ${warn(identityWarning.reason)}`);
lines.push(`${label("Vector search")} ${warn("paused until memory is rebuilt")}`);
lines.push(`${label("Fix")} ${muted(identityWarning.fix)}`);
}
if (status.sourceCounts?.length) {
lines.push(label("By source"));
for (const entry of status.sourceCounts) {
@@ -1256,6 +1284,15 @@ export async function runMemorySearch(
defaultRuntime.writeJson({ results });
return;
}
const identityWarning =
typeof manager.status === "function"
? formatMemoryIndexIdentityWarning(manager.status(), agentId)
: null;
if (identityWarning) {
defaultRuntime.error(
`Memory index warning: ${identityWarning.reason}. Vector memory search is paused until the index is rebuilt. ${identityWarning.fix}`,
);
}
if (results.length === 0) {
defaultRuntime.log("No matches.");
return;

View File

@@ -415,6 +415,36 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
it("prints index identity mismatch reasons", async () => {
const close = vi.fn(async () => {});
mockManager({
status: () =>
makeMemoryStatus({
dirty: true,
provider: "ollama",
model: "nomic-embed-text",
requestedProvider: "ollama",
custom: {
indexIdentity: {
status: "mismatched",
reason: "index was built for provider openai, expected ollama",
},
},
}),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expectLogged(log, "Provider: ollama (requested: ollama)");
expectLogged(log, "Dirty: yes");
expectLogged(log, "Index identity: index was built for provider openai, expected ollama");
expectLogged(log, "Vector search: paused until memory is rebuilt");
expectLogged(log, "Fix: Run: openclaw memory status --index --agent main");
expect(close).toHaveBeenCalled();
});
it("keeps plain status from probing vector or embeddings", async () => {
const close = vi.fn(async () => {});
const probeVectorAvailability = vi.fn(async () => {

View File

@@ -1682,7 +1682,8 @@ describe("gateway startup reconciliation", () => {
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
expect(harness.addCalls).toHaveLength(0);
expectLogContains(logger.warn, "cron service unavailable");
expectLogNotContains(logger.warn, "cron service unavailable");
expectLogContains(logger.debug, "cron service not yet available at gateway_start");
cronAvailable = true;
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
@@ -1701,6 +1702,58 @@ describe("gateway startup reconciliation", () => {
}
});
it("keeps startup cron retry warnings quiet until the retry window is exhausted", async () => {
vi.useFakeTimers();
clearInternalHooks();
const logger = createLogger();
const onMock = vi.fn();
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
},
},
},
},
},
pluginConfig: {},
logger,
runtime: {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => undefined,
});
expectLogContains(logger.debug, "cron service not yet available at gateway_start");
await vi.advanceTimersByTimeAsync(
constants.STARTUP_CRON_RETRY_DELAY_MS * (constants.STARTUP_CRON_RETRY_MAX_ATTEMPTS - 1),
);
expectLogNotContains(logger.warn, "cron service unavailable");
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
expectLogContains(logger.warn, "cron service unavailable");
expect(logger.warn).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
await triggerGatewayStop(onMock);
clearInternalHooks();
}
});
it("retries disabled startup cleanup until cron is available", async () => {
vi.useFakeTimers();
clearInternalHooks();
@@ -1829,6 +1882,67 @@ describe("gateway startup reconciliation", () => {
}
});
it("does not recreate startup cron from stale enabled config after live memory-core config is removed", async () => {
vi.useFakeTimers();
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const runtimeCurrentConfig = vi.fn(
() =>
({
plugins: {
entries: {},
},
}) as OpenClawConfig,
);
const api: DreamingPluginApiTestDouble = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
},
},
},
},
} as OpenClawConfig,
pluginConfig: {},
logger,
runtime: {
config: {
current: runtimeCurrentConfig,
},
},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
let cronAvailable = false;
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => (cronAvailable ? harness.cron : undefined),
});
cronAvailable = true;
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
expect(runtimeCurrentConfig).toHaveBeenCalled();
expect(harness.addCalls).toHaveLength(0);
expectLogNotContains(logger.warn, "cron service unavailable");
} finally {
vi.useRealTimers();
await triggerGatewayStop(onMock).catch(() => undefined);
clearInternalHooks();
}
});
it("clears pending startup cron retry on gateway stop", async () => {
vi.useFakeTimers();
clearInternalHooks();

View File

@@ -760,18 +760,18 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
].join("|");
const reconcileManagedDreamingCron = async (params: {
reason: "startup" | "runtime";
reason: "startup" | "startup_retry" | "runtime";
startupConfig?: OpenClawConfig;
startupCron?: (() => CronServiceLike | null) | null;
}): Promise<ShortTermPromotionDreamingConfig> => {
const startupCfg =
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
const pluginConfig =
params.reason === "runtime"
? resolveMemoryCorePluginConfig(startupCfg)
: (resolveMemoryCorePluginConfig(startupCfg) ??
params.reason === "startup"
? (resolveMemoryCorePluginConfig(startupCfg) ??
resolveMemoryCorePluginConfig(api.config) ??
api.pluginConfig);
api.pluginConfig)
: resolveMemoryCorePluginConfig(startupCfg);
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig,
cfg: startupCfg,
@@ -784,7 +784,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
// This handles the case where the cron service was not yet available during
// gateway_start (250ms deferred init race in startGatewaySidecars) but is
// available now. Fixes #67362.
if (!cron && params.reason === "runtime" && gatewayContext) {
if (!cron && params.reason !== "startup" && gatewayContext) {
try {
cron = resolveCronServiceFromGatewayContext(gatewayContext);
if (cron) {
@@ -800,7 +800,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
// Avoid a noisy startup-path warning when the gateway has not exposed cron yet.
// The runtime reconciliation path (heartbeat-driven) will still warn if the
// cron service remains unavailable after boot.
if (params.reason === "startup") {
if (params.reason === "startup" || params.reason === "startup_retry") {
api.logger.debug?.(
"memory-core: cron service not yet available at gateway_start; deferring to runtime reconciliation.",
);
@@ -815,6 +815,11 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
unavailableCronWarningEmitted = false;
clearStartupCronRetry();
}
// Startup retries only probe cron availability; the exhausted retry path
// re-enters runtime reconciliation so persistent failures still warn once.
if (!cron && params.reason === "startup_retry") {
return config;
}
if (params.reason === "runtime") {
const now = Date.now();
const withinThrottleWindow =
@@ -852,12 +857,16 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
return;
}
startupCronRetryAttempts += 1;
void reconcileManagedDreamingCron({ reason: "runtime" })
.then(() => {
void reconcileManagedDreamingCron({ reason: "startup_retry" })
.then(async () => {
if (disposed || hasStartupCron()) {
clearStartupCronRetry();
return;
}
if (startupCronRetryAttempts >= STARTUP_CRON_RETRY_MAX_ATTEMPTS) {
await reconcileManagedDreamingCron({ reason: "runtime" });
return;
}
scheduleStartupCronRetry();
})
.catch((err: unknown) => {

View File

@@ -86,6 +86,10 @@ export function setMemoryWorkspaceDir(next: string): void {
workspaceDir = next;
}
export function setMemoryCustomStatus(next: Record<string, unknown> | undefined): void {
customStatus = next;
}
export function setMemorySearchImpl(next: SearchImpl): void {
searchImpl = next;
}
@@ -130,6 +134,10 @@ export function getMemorySearchManagerMockCalls(): number {
return getMemorySearchManagerMock.mock.calls.length;
}
export function getMemorySyncMockCalls(): number {
return stubManager.sync.mock.calls.length;
}
export function getMemorySearchManagerMockConfigs(): unknown[] {
return getMemorySearchManagerMock.mock.calls.map(([params]) => params.cfg);
}

View File

@@ -26,6 +26,7 @@ export function resetEmbeddingMocks(): void {
}
vi.mock("./embeddings.js", () => ({
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {

View File

@@ -146,6 +146,17 @@ export function resolveEmbeddingProviderFallbackModel(
return adapter?.defaultModel ?? fallbackSourceModel;
}
export function resolveEmbeddingProviderAdapterId(
providerId: string,
config?: MemoryEmbeddingProviderCreateOptions["config"],
): string | undefined {
try {
return getAdapter(providerId, config).id;
} catch {
return undefined;
}
}
async function createWithAdapter(
adapter: MemoryEmbeddingProviderAdapter,
options: CreateEmbeddingProviderOptions,

View File

@@ -13,6 +13,7 @@ import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import { LOCAL_EMBEDDING_WORKER_ERROR_CODES } from "./manager-local-worker-errors.js";
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
import {
DEFAULT_LOCAL_MODEL,
@@ -58,6 +59,14 @@ vi.mock("./embeddings.js", () => {
providerId === "gemini" || providerId === "fallback-provider"
? `${providerId}-embed`
: fallbackSourceModel,
resolveEmbeddingProviderAdapterId: (
providerId: string,
config?: {
models?: {
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
};
},
) => config?.models?.providers?.[providerId]?.api ?? providerId,
createEmbeddingProvider: async (options: {
provider?: string;
model?: string;
@@ -77,7 +86,9 @@ vi.mock("./embeddings.js", () => {
};
}
const providerId =
options.provider === "gemini" || options.provider === "fallback-provider"
options.provider === "gemini" ||
options.provider === "fallback-provider" ||
options.provider === "ollama"
? options.provider
: "mock";
const model = options.model ?? "mock-embed";
@@ -261,8 +272,9 @@ describe("memory index", () => {
extraPaths?: string[];
sources?: Array<"memory" | "sessions">;
sessionMemory?: boolean;
provider?: "openai" | "gemini" | "fallback-provider";
provider?: string;
fallback?: "none" | "gemini" | "fallback-provider";
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
model?: string;
outputDimensionality?: number;
multimodal?: {
@@ -302,6 +314,7 @@ describe("memory index", () => {
},
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
};
}
@@ -323,9 +336,12 @@ describe("memory index", () => {
return manager;
}
async function getFreshManager(cfg: TestCfg): Promise<MemoryIndexManager> {
async function getFreshManager(
cfg: TestCfg,
purpose?: "default" | "status" | "cli",
): Promise<MemoryIndexManager> {
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
return await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
return await getRequiredMemoryIndexManager({ cfg, agentId: "main", purpose });
}
async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) {
@@ -389,6 +405,406 @@ describe("memory index", () => {
}
});
it("does not full-reindex on search when existing metadata belongs to another provider", async () => {
const dbPath = path.join(workspaceDir, "index-provider-cutover.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
model: "old-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextCfg = createCfg({
storePath: dbPath,
provider: "gemini",
model: "new-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const nextManager = await getFreshManager(nextCfg);
try {
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "mismatched",
reason: "index was built for model old-embed, expected new-embed",
});
embedBatchCalls = 0;
const results = await nextManager.search("alpha");
expect(results).toStrictEqual([]);
expect(embedBatchCalls).toBe(0);
expect(nextManager.status().dirty).toBe(true);
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line changed.\nZebra memory line.",
);
await nextManager.sync({ reason: "watch" });
expect(embedBatchCalls).toBe(0);
const stillPausedResults = await nextManager.search("alpha");
expect(stillPausedResults).toStrictEqual([]);
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "mismatched",
reason: "index was built for model old-embed, expected new-embed",
});
} finally {
await nextManager.close?.();
}
});
it("keeps status clean when configured provider alias resolves to indexed adapter", async () => {
const dbPath = path.join(workspaceDir, "index-provider-alias-status.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
provider: "ollama",
model: "ollama-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const aliasCfg = createCfg({
storePath: dbPath,
provider: "ollama-west",
providerAliases: {
"ollama-west": {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
model: "ollama-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const statusManager = await getFreshManager(aliasCfg, "status");
try {
const status = statusManager.status();
expect(status.dirty).toBe(false);
expect(status.custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await statusManager.close?.();
}
});
it("does not search stale rows when index metadata is missing", async () => {
const dbPath = path.join(workspaceDir, "index-missing-meta-cutover.sqlite");
const cfg = createCfg({
storePath: dbPath,
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const oldManager = await getFreshManager(cfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
await fs.rm(path.join(memoryDir, "2026-01-12.md"));
const nextManager = await getFreshManager(cfg);
try {
(
nextManager as unknown as {
db: { exec: (sql: string) => void };
}
).db.exec(`DELETE FROM meta WHERE key = 'memory_index_meta_v1'`);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "missing",
reason: "index metadata is missing",
});
const results = await nextManager.search("alpha");
expect(results).toStrictEqual([]);
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "missing",
reason: "index metadata is missing",
});
} finally {
await nextManager.close?.();
}
});
it("does not search stale provider rows after embeddings become unavailable", async () => {
const dbPath = path.join(workspaceDir, "index-provider-unavailable-cutover.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
model: "semantic-embed",
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
forceNoProvider = true;
const nextManager = await getFreshManager(oldCfg);
try {
const results = await nextManager.search("alpha");
expect(results).toStrictEqual([]);
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toMatchObject({
status: "mismatched",
});
} finally {
await nextManager.close?.();
}
});
it("clears dirty after sessions-only identity reindex", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-only-reindex"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "session-identity.jsonl"),
[
JSON.stringify({
type: "session",
id: "session-identity",
timestamp: "2026-04-07T15:24:04.113Z",
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-07T15:25:04.113Z",
content: [{ type: "text", text: "Session-only identity marker." }],
},
}),
].join("\n") + "\n",
"utf8",
);
const dbPath = path.join(workspaceDir, "index-sessions-only-cutover.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
sources: ["sessions"],
sessionMemory: true,
model: "old-embed",
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextCfg = createCfg({
storePath: dbPath,
sources: ["sessions"],
sessionMemory: true,
provider: "gemini",
model: "new-embed",
});
const nextManager = await getFreshManager(nextCfg);
try {
expect(nextManager.status().dirty).toBe(true);
await nextManager.sync({ reason: "test", force: true });
expect(nextManager.status().dirty).toBe(false);
expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
}
});
it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-missing-meta"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "session-missing-meta.jsonl"),
[
JSON.stringify({
type: "session",
id: "session-missing-meta",
timestamp: "2026-04-07T15:24:04.113Z",
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-07T15:25:04.113Z",
content: [{ type: "text", text: "Sessions missing metadata marker." }],
},
}),
].join("\n") + "\n",
"utf8",
);
const dbPath = path.join(workspaceDir, "index-sessions-missing-meta.sqlite");
const cfg = createCfg({
storePath: dbPath,
sources: ["sessions"],
sessionMemory: true,
});
const oldManager = await getFreshManager(cfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextManager = await getFreshManager(cfg);
try {
(
nextManager as unknown as {
db: { exec: (sql: string) => void };
}
).db.exec(`DELETE FROM meta WHERE key = 'memory_index_meta_v1'`);
const status = nextManager.status();
expect(status.dirty).toBe(true);
expect(status.custom?.indexIdentity).toEqual({
status: "missing",
reason: "index metadata is missing",
});
} finally {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
}
});
it("keeps provider cutover vector search paused during targeted session sync", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-targeted-cutover"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl");
await fs.writeFile(
sessionFile,
[
JSON.stringify({
type: "session",
id: "session-targeted-cutover",
timestamp: "2026-04-07T15:24:04.113Z",
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-07T15:25:04.113Z",
content: [{ type: "text", text: "Targeted cutover marker." }],
},
}),
].join("\n") + "\n",
"utf8",
);
const dbPath = path.join(workspaceDir, "index-targeted-session-cutover.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
sources: ["memory", "sessions"],
sessionMemory: true,
model: "old-embed",
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextCfg = createCfg({
storePath: dbPath,
sources: ["memory", "sessions"],
sessionMemory: true,
provider: "gemini",
model: "new-embed",
});
const nextManager = await getFreshManager(nextCfg);
try {
expect(nextManager.status().dirty).toBe(true);
embedBatchCalls = 0;
await nextManager.sync({ reason: "test", sessionFiles: [sessionFile] });
expect(embedBatchCalls).toBe(0);
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({
status: "mismatched",
reason: "index was built for model old-embed, expected new-embed",
});
const results = await nextManager.search("alpha");
expect(results).toStrictEqual([]);
} finally {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
}
});
it("preserves memory dirty events raised during session identity reindex", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-dirty-during-session"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "session-dirty-during-reindex.jsonl"),
[
JSON.stringify({
type: "session",
id: "session-dirty-during-reindex",
timestamp: "2026-04-07T15:24:04.113Z",
}),
JSON.stringify({
type: "message",
message: {
role: "assistant",
timestamp: "2026-04-07T15:25:04.113Z",
content: [{ type: "text", text: "Dirty during session marker." }],
},
}),
].join("\n") + "\n",
"utf8",
);
const dbPath = path.join(workspaceDir, "index-dirty-during-session.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
sources: ["memory", "sessions"],
sessionMemory: true,
model: "old-embed",
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const nextCfg = createCfg({
storePath: dbPath,
sources: ["memory", "sessions"],
sessionMemory: true,
provider: "gemini",
model: "new-embed",
});
const nextManager = await getFreshManager(nextCfg);
try {
const fields = nextManager as unknown as {
dirty: boolean;
syncSessionFiles: (params: unknown) => Promise<void>;
};
const syncSessionFiles = fields.syncSessionFiles.bind(nextManager);
fields.syncSessionFiles = async (params) => {
fields.dirty = true;
await syncSessionFiles(params);
};
await nextManager.sync({ reason: "test", force: true });
expect(nextManager.status().dirty).toBe(true);
expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
}
});
it("closes embedding providers when memory index managers close", async () => {
const cfg = createCfg({
storePath: indexMainPath,
@@ -593,7 +1009,7 @@ describe("memory index", () => {
waitForEmbeddingRetry: (delayMs: number, action: string) => Promise<void>;
}
).provider = {
id: "openai",
id: "mock",
model: "mock-embed",
embedQuery: async () => {
queryCalls += 1;
@@ -637,7 +1053,7 @@ describe("memory index", () => {
};
}
).provider = {
id: "openai",
id: "mock",
model: "mock-embed",
embedQuery: async () => {
queryCalls += 1;
@@ -696,6 +1112,76 @@ describe("memory index", () => {
expect(status.vector?.available).toBeUndefined();
});
it("marks older vector indexes dirty after vector store probing", async () => {
const dbPath = path.join(workspaceDir, "index-vector-missing-dims.sqlite");
const legacyCfg = createCfg({
storePath: dbPath,
provider: "gemini",
vectorEnabled: false,
});
const legacyManager = await getFreshManager(legacyCfg);
await legacyManager.sync({ reason: "test", force: true });
await legacyManager.close?.();
const cfg = createCfg({
storePath: dbPath,
provider: "gemini",
vectorEnabled: true,
});
const manager = await getFreshManager(cfg);
try {
const metaAccess = manager as unknown as {
readMeta(): MemoryIndexMeta | null;
};
const meta = metaAccess.readMeta();
if (!meta) {
throw new Error("expected index metadata");
}
expect(meta.vectorDims).toBeUndefined();
await manager.probeVectorStoreAvailability?.();
const status = manager.status();
expect(status.dirty).toBe(true);
expect(status.custom?.indexIdentity).toEqual({
status: "mismatched",
reason: "index vector dimensions are missing",
});
} finally {
await manager.close?.();
}
});
it("keeps empty vector indexes clean after vector store probing", async () => {
await fs.rm(path.join(memoryDir, "2026-01-12.md"));
const dbPath = path.join(workspaceDir, "index-empty-vector.sqlite");
const legacyCfg = createCfg({
storePath: dbPath,
provider: "gemini",
vectorEnabled: false,
});
const legacyManager = await getFreshManager(legacyCfg);
await legacyManager.sync({ reason: "test", force: true });
await legacyManager.close?.();
const cfg = createCfg({
storePath: dbPath,
provider: "gemini",
vectorEnabled: true,
});
const manager = await getFreshManager(cfg, "status");
try {
await manager.probeVectorStoreAvailability?.();
const status = manager.status();
expect(status.dirty).toBe(false);
expect(status.custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await manager.close?.();
}
});
it("caches embedding probe readiness across transient status managers", async () => {
const cfg = createCfg({ storePath: path.join(workspaceDir, "index-probe-cache.sqlite") });
const first = requireManager(
@@ -778,7 +1264,7 @@ describe("memory index", () => {
});
});
it("activates configured fallback when local embeddings degrade during search", async () => {
it("does not activate fallback during search when index identity is already mismatched", async () => {
const cfg = createCfg({
storePath: path.join(workspaceDir, "index-search-degraded-fallback.sqlite"),
fallback: "fallback-provider",
@@ -810,21 +1296,68 @@ describe("memory index", () => {
const results = await manager.search("alpha");
expect(results.length).toBeGreaterThan(0);
const resultKeys = results.map(
(result) => `${result.source}:${result.path}:${result.startLine}:${result.endLine}`,
);
expect(new Set(resultKeys).size).toBe(resultKeys.length);
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain(
"fallback-provider",
);
expect(results).toStrictEqual([]);
expect(providerCalls.slice(callsBeforeSearch)).toStrictEqual([]);
expect(
(
manager as unknown as {
provider: { id: string } | null;
}
).provider?.id,
).toBe("fallback-provider");
).toBe("local");
});
it("rebuilds with fallback provider during explicit identity repair", async () => {
const dbPath = path.join(workspaceDir, "index-cli-fallback-identity-repair.sqlite");
const oldCfg = createCfg({
storePath: dbPath,
model: "old-embed",
});
const oldManager = await getFreshManager(oldCfg);
await oldManager.sync({ reason: "test", force: true });
await oldManager.close?.();
const cfg = createCfg({
storePath: dbPath,
model: "new-embed",
fallback: "fallback-provider",
});
const manager = await getFreshManager(cfg);
try {
expect(manager.status().dirty).toBe(true);
const fields = manager as unknown as {
providerInitialized: boolean;
provider: {
id: string;
model: string;
embedQuery: (text: string) => Promise<number[]>;
embedBatch: (texts: string[]) => Promise<number[][]>;
close: () => Promise<void>;
};
};
fields.providerInitialized = true;
fields.provider = {
id: "mock",
model: "new-embed",
embedQuery: async () => {
throw createLocalWorkerExitError();
},
embedBatch: async () => {
throw createLocalWorkerExitError();
},
close: async () => {},
};
await manager.sync({ reason: "cli" });
expect(manager.status().dirty).toBe(false);
expect(manager.status().provider).toBe("fallback-provider");
expect(manager.status().model).toBe("fallback-provider-embed");
expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" });
await expect(manager.search("alpha")).resolves.not.toStrictEqual([]);
} finally {
await manager.close?.();
}
});
it("activates configured fallback after probe-time local degradation", async () => {
@@ -866,7 +1399,7 @@ describe("memory index", () => {
const results = await manager.search("alpha");
expect(results.length).toBeGreaterThan(0);
expect(results).toStrictEqual([]);
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain(
"fallback-provider",
);
@@ -879,6 +1412,73 @@ describe("memory index", () => {
).toBe("fallback-provider");
});
it("clears identity dirty after status resolves the indexed fallback provider", async () => {
const dbPath = path.join(workspaceDir, "index-status-fallback-identity.sqlite");
const indexedCfg = createCfg({
storePath: dbPath,
provider: "fallback-provider",
model: "new-embed",
});
const indexedManager = await getFreshManager(indexedCfg);
await indexedManager.sync({ reason: "test", force: true });
await indexedManager.close?.();
const cfg = createCfg({
storePath: dbPath,
fallback: "fallback-provider",
model: "new-embed",
});
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
const manager = await getRequiredMemoryIndexManager({
cfg,
agentId: "main",
purpose: "status",
});
try {
expect(manager.status().dirty).toBe(true);
const fields = manager as unknown as {
provider: {
id: string;
model: string;
embedQuery: (text: string) => Promise<number[]>;
embedBatch: (texts: string[]) => Promise<number[][]>;
close: () => Promise<void>;
};
providerInitialized: boolean;
providerRuntime: {
id: string;
cacheKeyData: Record<string, unknown>;
};
providerKey: string;
computeProviderKey: () => string;
};
fields.provider = {
id: "fallback-provider",
model: "new-embed",
embedQuery: async () => [1, 0, 0, 0],
embedBatch: async (texts) => texts.map(() => [1, 0, 0, 0]),
close: async () => {},
};
fields.providerRuntime = {
id: "fallback-provider",
cacheKeyData: {
provider: "fallback-provider",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model: "new-embed",
headers: [],
},
};
fields.providerInitialized = true;
fields.providerKey = fields.computeProviderKey();
expect(manager.status().dirty).toBe(false);
expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" });
} finally {
await manager.close?.();
}
});
it("streams embedding cache rows during safe reindex", async () => {
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
type EmbeddingCacheRow = {

View File

@@ -3,7 +3,8 @@ import { describe, expect, it } from "vitest";
import {
resolveConfiguredScopeHash,
resolveConfiguredSourcesForMeta,
shouldRunFullMemoryReindex,
resolveMemoryIndexIdentityState,
isMemoryIndexIdentityDirty,
type MemoryIndexMeta,
} from "./manager-reindex-state.js";
@@ -21,16 +22,18 @@ function createMeta(overrides: Partial<MemoryIndexMeta> = {}): MemoryIndexMeta {
};
}
function createFullReindexParams(
function createIdentityParams(
overrides: {
meta?: MemoryIndexMeta | null;
provider?: { id: string; model: string } | null;
providerKey?: string;
providerKeyKnown?: boolean;
configuredSources?: MemorySource[];
configuredScopeHash?: string;
chunkTokens?: number;
chunkOverlap?: number;
vectorReady?: boolean;
hasIndexedChunks?: boolean;
ftsTokenizer?: string;
} = {},
) {
@@ -43,26 +46,41 @@ function createFullReindexParams(
chunkTokens: 4000,
chunkOverlap: 0,
vectorReady: false,
hasIndexedChunks: true,
ftsTokenizer: "unicode61",
...overrides,
};
}
describe("memory reindex state", () => {
it("requires a full reindex when the embedding model changes", () => {
it("marks identity dirty when the embedding model changes", () => {
expect(
shouldRunFullMemoryReindex(
createFullReindexParams({
isMemoryIndexIdentityDirty(
createIdentityParams({
provider: { id: "openai", model: "mock-embed-v2" },
}),
),
).toBe(true);
});
it("requires a full reindex when the provider cache key changes", () => {
it("returns a mismatch reason when provider identity changes", () => {
expect(
shouldRunFullMemoryReindex(
createFullReindexParams({
resolveMemoryIndexIdentityState(
createIdentityParams({
provider: { id: "ollama", model: "mock-embed-v1" },
providerKey: "provider-key-ollama",
}),
),
).toEqual({
status: "mismatched",
reason: "index was built for provider openai, expected ollama",
});
});
it("marks identity dirty when the provider cache key changes", () => {
expect(
isMemoryIndexIdentityDirty(
createIdentityParams({
provider: { id: "gemini", model: "gemini-embedding-2-preview" },
providerKey: "provider-key-dims-768",
meta: createMeta({
@@ -75,7 +93,30 @@ describe("memory reindex state", () => {
).toBe(true);
});
it("requires a full reindex when extraPaths change", () => {
it("can defer provider key comparison until provider initialization", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
providerKey: undefined,
providerKeyKnown: false,
}),
),
).toEqual({ status: "valid" });
});
it("does not mark identity dirty for vector dimensions before chunks exist", () => {
expect(
resolveMemoryIndexIdentityState(
createIdentityParams({
vectorReady: true,
hasIndexedChunks: false,
meta: createMeta({ vectorDims: undefined }),
}),
),
).toEqual({ status: "valid" });
});
it("marks identity dirty when extraPaths change", () => {
const workspaceDir = "/tmp/workspace";
const firstScopeHash = resolveConfiguredScopeHash({
workspaceDir,
@@ -97,8 +138,8 @@ describe("memory reindex state", () => {
});
expect(
shouldRunFullMemoryReindex(
createFullReindexParams({
isMemoryIndexIdentityDirty(
createIdentityParams({
meta: createMeta({ scopeHash: firstScopeHash }),
configuredScopeHash: secondScopeHash,
}),
@@ -106,17 +147,17 @@ describe("memory reindex state", () => {
).toBe(true);
});
it("requires a full reindex when configured sources add sessions", () => {
it("marks identity dirty when configured sources add sessions", () => {
expect(
shouldRunFullMemoryReindex(
createFullReindexParams({
isMemoryIndexIdentityDirty(
createIdentityParams({
configuredSources: ["memory", "sessions"],
}),
),
).toBe(true);
});
it("requires a full reindex when multimodal settings change", () => {
it("marks identity dirty when multimodal settings change", () => {
const workspaceDir = "/tmp/workspace";
const firstScopeHash = resolveConfiguredScopeHash({
workspaceDir,
@@ -138,8 +179,8 @@ describe("memory reindex state", () => {
});
expect(
shouldRunFullMemoryReindex(
createFullReindexParams({
isMemoryIndexIdentityDirty(
createIdentityParams({
meta: createMeta({ scopeHash: firstScopeHash }),
configuredScopeHash: secondScopeHash,
}),
@@ -149,8 +190,8 @@ describe("memory reindex state", () => {
it("keeps older indexes with missing sources compatible with memory-only config", () => {
expect(
shouldRunFullMemoryReindex(
createFullReindexParams({
isMemoryIndexIdentityDirty(
createIdentityParams({
meta: createMeta({ sources: undefined }),
configuredSources: resolveConfiguredSourcesForMeta(new Set(["memory"])),
}),

View File

@@ -16,6 +16,19 @@ export type MemoryIndexMeta = {
ftsTokenizer?: string;
};
export type MemoryIndexIdentityState =
| {
status: "valid";
}
| {
status: "missing";
reason: string;
}
| {
status: "mismatched";
reason: string;
};
export function resolveConfiguredSourcesForMeta(sources: Iterable<MemorySource>): MemorySource[] {
const normalized = Array.from(sources)
.filter((source): source is MemorySource => source === "memory" || source === "sessions")
@@ -73,31 +86,93 @@ export function resolveConfiguredScopeHash(params: {
);
}
export function shouldRunFullMemoryReindex(params: {
export function isMemoryIndexIdentityDirty(params: {
meta: MemoryIndexMeta | null;
provider: { id: string; model: string } | null;
providerKey?: string;
providerKeyKnown?: boolean;
configuredSources: MemorySource[];
configuredScopeHash: string;
chunkTokens: number;
chunkOverlap: number;
vectorReady: boolean;
hasIndexedChunks?: boolean;
ftsTokenizer: string;
}): boolean {
return resolveMemoryIndexIdentityState(params).status !== "valid";
}
export function resolveMemoryIndexIdentityState(params: {
meta: MemoryIndexMeta | null;
provider: { id: string; model: string } | null;
providerKey?: string;
providerKeyKnown?: boolean;
configuredSources: MemorySource[];
configuredScopeHash: string;
chunkTokens: number;
chunkOverlap: number;
vectorReady: boolean;
hasIndexedChunks?: boolean;
ftsTokenizer: string;
}): MemoryIndexIdentityState {
const { meta } = params;
return (
!meta ||
(params.provider ? meta.model !== params.provider.model : meta.model !== "fts-only") ||
(params.provider ? meta.provider !== params.provider.id : meta.provider !== "none") ||
meta.providerKey !== params.providerKey ||
if (!meta) {
return { status: "missing", reason: "index metadata is missing" };
}
const expectedModel = params.provider ? params.provider.model : "fts-only";
if (meta.model !== expectedModel) {
return {
status: "mismatched",
reason: `index was built for model ${meta.model}, expected ${expectedModel}`,
};
}
const expectedProvider = params.provider ? params.provider.id : "none";
if (meta.provider !== expectedProvider) {
return {
status: "mismatched",
reason: `index was built for provider ${meta.provider}, expected ${expectedProvider}`,
};
}
if (params.providerKeyKnown !== false && meta.providerKey !== params.providerKey) {
return {
status: "mismatched",
reason: "index provider settings changed",
};
}
if (
configuredMetaSourcesDiffer({
meta,
configuredSources: params.configuredSources,
}) ||
meta.scopeHash !== params.configuredScopeHash ||
meta.chunkTokens !== params.chunkTokens ||
meta.chunkOverlap !== params.chunkOverlap ||
(params.vectorReady && !meta.vectorDims) ||
(meta.ftsTokenizer ?? "unicode61") !== params.ftsTokenizer
);
})
) {
return {
status: "mismatched",
reason: "index sources changed",
};
}
if (meta.scopeHash !== params.configuredScopeHash) {
return {
status: "mismatched",
reason: "index scope changed",
};
}
if (meta.chunkTokens !== params.chunkTokens || meta.chunkOverlap !== params.chunkOverlap) {
return {
status: "mismatched",
reason: "index chunking changed",
};
}
if (params.vectorReady && params.hasIndexedChunks !== false && !meta.vectorDims) {
return {
status: "mismatched",
reason: "index vector dimensions are missing",
};
}
if ((meta.ftsTokenizer ?? "unicode61") !== params.ftsTokenizer) {
return {
status: "mismatched",
reason: "index FTS tokenizer changed",
};
}
return { status: "valid" };
}

View File

@@ -573,7 +573,11 @@ describe("searchVector sqlite-vec KNN", () => {
function insertFallbackChunk(
db: InstanceType<typeof DatabaseSync>,
params: { id: string; model: string; vector: number[] },
params: {
id: string;
model: string;
vector: number[];
},
): void {
db.prepare(
"INSERT INTO chunks (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",

View File

@@ -28,6 +28,17 @@ describe("memory manager status state", () => {
).toBe(true);
});
it("marks status-only managers dirty when index identity mismatches", () => {
expect(
resolveInitialMemoryDirty({
hasMemorySource: false,
statusOnly: true,
hasIndexedMeta: true,
indexIdentityMismatched: true,
}),
).toBe(true);
});
it("reports the requested provider before provider initialization", () => {
expect(
resolveStatusProviderInfo({

View File

@@ -27,8 +27,12 @@ export function resolveInitialMemoryDirty(params: {
hasMemorySource: boolean;
statusOnly: boolean;
hasIndexedMeta: boolean;
indexIdentityMismatched?: boolean;
}): boolean {
return params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true);
return (
Boolean(params.indexIdentityMismatched) ||
(params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true))
);
}
export function resolveStatusProviderInfo(params: {

View File

@@ -38,6 +38,7 @@ import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
createEmbeddingProvider,
resolveEmbeddingProviderAdapterId,
type EmbeddingProvider,
type EmbeddingProviderId,
type EmbeddingProviderRuntime,
@@ -54,8 +55,9 @@ import {
import {
resolveConfiguredScopeHash,
resolveConfiguredSourcesForMeta,
shouldRunFullMemoryReindex,
resolveMemoryIndexIdentityState,
type MemoryIndexMeta,
type MemoryIndexIdentityState,
} from "./manager-reindex-state.js";
import { shouldSyncSessionsForReindex } from "./manager-session-reindex.js";
import {
@@ -67,7 +69,10 @@ import {
loadMemorySourceFileState,
resolveMemorySourceExistingHash,
} from "./manager-source-state.js";
import { runMemoryTargetedSessionSync } from "./manager-targeted-sync.js";
import {
markMemoryTargetSessionFilesDirty,
runMemoryTargetedSessionSync,
} from "./manager-targeted-sync.js";
import {
recordMemoryWatchEventPath,
settleMemoryWatchEventPaths,
@@ -269,6 +274,65 @@ export abstract class MemoryManagerSyncOps {
options: { source: MemorySource; content?: string },
): Promise<void>;
protected hasIndexedChunks(): boolean {
const row = this.db.prepare(`SELECT 1 as found FROM chunks LIMIT 1`).get() as
| { found?: number }
| undefined;
return row?.found === 1;
}
protected resolveCurrentIndexIdentityState(params?: {
meta?: MemoryIndexMeta | null;
provider?: { id: string; model: string } | null;
providerKeyKnown?: boolean;
vectorReady?: boolean;
hasIndexedChunks?: boolean;
}): MemoryIndexIdentityState {
const hasProviderOverride = params && "provider" in params;
const configuredProvider =
this.settings.provider === "none"
? null
: {
id:
resolveEmbeddingProviderAdapterId(this.settings.provider, this.cfg) ??
this.settings.provider,
model: this.settings.model,
};
const provider = hasProviderOverride
? params.provider!
: this.provider
? { id: this.provider.id, model: this.provider.model }
: configuredProvider;
const vectorReady =
params && "vectorReady" in params
? Boolean(params.vectorReady)
: this.vector.available === true;
return resolveMemoryIndexIdentityState({
meta: params && "meta" in params ? params.meta! : this.readMeta(),
provider,
providerKey: params?.providerKeyKnown === false ? undefined : (this.providerKey ?? undefined),
providerKeyKnown: params?.providerKeyKnown,
configuredSources: resolveConfiguredSourcesForMeta(this.sources),
configuredScopeHash: resolveConfiguredScopeHash({
workspaceDir: this.workspaceDir,
extraPaths: this.settings.extraPaths,
multimodal: {
enabled: this.settings.multimodal.enabled,
modalities: this.settings.multimodal.modalities,
maxFileBytes: this.settings.multimodal.maxFileBytes,
},
}),
chunkTokens: this.settings.chunking.tokens,
chunkOverlap: this.settings.chunking.overlap,
vectorReady,
hasIndexedChunks:
params && "hasIndexedChunks" in params
? Boolean(params.hasIndexedChunks)
: this.hasIndexedChunks(),
ftsTokenizer: this.settings.store.fts.tokenizer,
});
}
protected resetVectorState(): void {
this.vectorReady = null;
this.vector.available = null;
@@ -1691,60 +1755,69 @@ export abstract class MemoryManagerSyncOps {
}
const vectorReady = await this.ensureVectorReady();
const meta = this.readMeta();
const configuredSources = resolveConfiguredSourcesForMeta(this.sources);
const configuredScopeHash = resolveConfiguredScopeHash({
workspaceDir: this.workspaceDir,
extraPaths: this.settings.extraPaths,
multimodal: {
enabled: this.settings.multimodal.enabled,
modalities: this.settings.multimodal.modalities,
maxFileBytes: this.settings.multimodal.maxFileBytes,
},
});
const targetSessionFiles = this.normalizeTargetSessionFiles(params?.sessionFiles);
const hasTargetSessionFiles = targetSessionFiles !== null;
if (params?.reason === "cli" && !params.force && !hasTargetSessionFiles) {
await this.markSessionStartupCatchupDirtyFiles();
}
const targetedSessionSync = await runMemoryTargetedSessionSync({
hasSessionSource: this.sources.has("sessions"),
targetSessionFiles,
reason: params?.reason,
progress: progress ?? undefined,
useUnsafeReindex:
process.env.OPENCLAW_TEST_FAST === "1" &&
process.env.OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX === "1",
sessionsDirtyFiles: this.sessionsDirtyFiles,
syncSessionFiles: async (targetedParams) => {
await this.syncSessionFiles(targetedParams);
},
shouldFallbackOnError: (err) => this.shouldFallbackOnError(err),
activateFallbackProvider: async (reason) => await this.activateFallbackProvider(reason),
runSafeReindex: async (reindexParams) => {
await this.runSafeReindex(reindexParams);
},
runUnsafeReindex: async (reindexParams) => {
await this.runUnsafeReindex(reindexParams);
},
const indexIdentity = resolveMemoryIndexIdentityState({
meta,
// Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up.
provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null,
providerKey: this.providerKey ?? undefined,
configuredSources: resolveConfiguredSourcesForMeta(this.sources),
configuredScopeHash: resolveConfiguredScopeHash({
workspaceDir: this.workspaceDir,
extraPaths: this.settings.extraPaths,
multimodal: {
enabled: this.settings.multimodal.enabled,
modalities: this.settings.multimodal.modalities,
maxFileBytes: this.settings.multimodal.maxFileBytes,
},
}),
chunkTokens: this.settings.chunking.tokens,
chunkOverlap: this.settings.chunking.overlap,
vectorReady,
hasIndexedChunks: this.hasIndexedChunks(),
ftsTokenizer: this.settings.store.fts.tokenizer,
});
if (targetedSessionSync.handled) {
this.sessionsDirty = targetedSessionSync.sessionsDirty;
return;
}
const hasIndexedChunks = this.hasIndexedChunks();
const needsInitialIndex = indexIdentity.status !== "valid" && !hasIndexedChunks;
const needsExplicitIdentityReindex =
params?.reason === "cli" && indexIdentity.status !== "valid" && !hasTargetSessionFiles;
const needsFullReindex =
(params?.force && !hasTargetSessionFiles) ||
shouldRunFullMemoryReindex({
meta,
// Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up.
provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null,
providerKey: this.providerKey ?? undefined,
configuredSources,
configuredScopeHash,
chunkTokens: this.settings.chunking.tokens,
chunkOverlap: this.settings.chunking.overlap,
vectorReady,
ftsTokenizer: this.settings.store.fts.tokenizer,
needsInitialIndex ||
needsExplicitIdentityReindex;
if (indexIdentity.status !== "valid" && !needsFullReindex) {
this.dirty = true;
const sessionsDirty = markMemoryTargetSessionFilesDirty({
sessionsDirtyFiles: this.sessionsDirtyFiles,
targetSessionFiles,
});
if (sessionsDirty) {
this.sessionsDirty = true;
}
return;
}
if (!needsFullReindex) {
const targetedSessionSync = await runMemoryTargetedSessionSync({
hasSessionSource: this.sources.has("sessions"),
targetSessionFiles,
reason: params?.reason,
progress: progress ?? undefined,
sessionsDirtyFiles: this.sessionsDirtyFiles,
syncSessionFiles: async (targetedParams) => {
await this.syncSessionFiles(targetedParams);
},
shouldFallbackOnError: (err) => this.shouldFallbackOnError(err),
activateFallbackProvider: async (reason) => await this.activateFallbackProvider(reason),
});
if (targetedSessionSync.handled) {
this.sessionsDirty = targetedSessionSync.sessionsDirty;
return;
}
}
try {
if (needsFullReindex) {
if (
@@ -1794,20 +1867,17 @@ export abstract class MemoryManagerSyncOps {
const activated =
this.shouldFallbackOnError(err) && (await this.activateFallbackProvider(reason));
if (activated) {
await this.runSafeReindex({
reason: params?.reason ?? "fallback",
force: true,
progress: progress ?? undefined,
});
if (needsFullReindex && !hasTargetSessionFiles) {
await this.runSafeReindex({
reason: params?.reason ?? "fallback",
force: true,
progress: progress ?? undefined,
});
}
return;
}
if (!this.provider && this.fts.enabled && this.shouldFallbackOnError(err)) {
log.warn(`memory embeddings unavailable; rebuilding lexical memory index only: ${reason}`);
await this.runSafeReindex({
reason: params?.reason ?? "embedding-degraded",
force: true,
progress: progress ?? undefined,
});
log.warn(`memory embeddings unavailable; leaving memory index dirty: ${reason}`);
return;
}
throw err;
@@ -1965,6 +2035,9 @@ export abstract class MemoryManagerSyncOps {
} else {
this.sessionsDirty = false;
}
if (!shouldSyncMemory) {
this.dirty = false;
}
const meta: MemoryIndexMeta = {
model: this.provider?.model ?? "fts-only",
@@ -2045,6 +2118,9 @@ export abstract class MemoryManagerSyncOps {
} else {
this.sessionsDirty = false;
}
if (!shouldSyncMemory) {
this.dirty = false;
}
const nextMeta: MemoryIndexMeta = {
model: this.provider?.model ?? "fts-only",

View File

@@ -38,6 +38,7 @@ vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => {
});
vi.mock("./embeddings.js", () => ({
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
createEmbeddingProvider: vi.fn(),
}));

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
clearMemorySyncedSessionFiles,
markMemoryTargetSessionFilesDirty,
runMemoryTargetedSessionSync,
} from "./manager-targeted-sync.js";
@@ -18,61 +19,48 @@ describe("memory targeted session sync", () => {
expect(sessionsDirty).toBe(true);
});
it("runs a full reindex after fallback activates during targeted sync", async () => {
const activateFallbackProvider = vi.fn(async () => true);
const runSafeReindex = vi.fn(async () => {});
const runUnsafeReindex = vi.fn(async () => {});
it("marks target sessions dirty while identity sync is paused", () => {
const targetSessionPath = "/tmp/paused-target.jsonl";
const sessionsDirtyFiles = new Set(["/tmp/other-dirty.jsonl"]);
await runMemoryTargetedSessionSync({
const sessionsDirty = markMemoryTargetSessionFilesDirty({
sessionsDirtyFiles,
targetSessionFiles: [targetSessionPath],
});
expect(sessionsDirty).toBe(true);
expect(sessionsDirtyFiles.has(targetSessionPath)).toBe(true);
expect(sessionsDirtyFiles.has("/tmp/other-dirty.jsonl")).toBe(true);
});
it("leaves targeted sessions dirty after fallback activates during targeted sync", async () => {
const activateFallbackProvider = vi.fn(async () => true);
const syncSessionFiles = vi
.fn()
.mockRejectedValueOnce(new Error("embedding backend failed"))
.mockResolvedValueOnce(undefined);
const sessionsDirtyFiles = new Set(["/tmp/targeted-fallback.jsonl", "/tmp/other-dirty.jsonl"]);
const result = await runMemoryTargetedSessionSync({
hasSessionSource: true,
targetSessionFiles: new Set(["/tmp/targeted-fallback.jsonl"]),
reason: "post-compaction",
progress: undefined,
useUnsafeReindex: false,
sessionsDirtyFiles: new Set(),
syncSessionFiles: async () => {
throw new Error("embedding backend failed");
},
sessionsDirtyFiles,
syncSessionFiles,
shouldFallbackOnError: () => true,
activateFallbackProvider,
runSafeReindex,
runUnsafeReindex,
});
expect(activateFallbackProvider).toHaveBeenCalledWith("embedding backend failed");
expect(runSafeReindex).toHaveBeenCalledWith({
reason: "post-compaction",
force: true,
expect(syncSessionFiles).toHaveBeenCalledTimes(1);
expect(syncSessionFiles).toHaveBeenCalledWith({
needsFullReindex: false,
targetSessionFiles: ["/tmp/targeted-fallback.jsonl"],
progress: undefined,
});
expect(runUnsafeReindex).not.toHaveBeenCalled();
});
it("uses the unsafe reindex path when enabled", async () => {
const runSafeReindex = vi.fn(async () => {});
const runUnsafeReindex = vi.fn(async () => {});
await runMemoryTargetedSessionSync({
hasSessionSource: true,
targetSessionFiles: new Set(["/tmp/targeted-fallback.jsonl"]),
reason: "post-compaction",
progress: undefined,
useUnsafeReindex: true,
sessionsDirtyFiles: new Set(),
syncSessionFiles: async () => {
throw new Error("embedding backend failed");
},
shouldFallbackOnError: () => true,
activateFallbackProvider: async () => true,
runSafeReindex,
runUnsafeReindex,
});
expect(runUnsafeReindex).toHaveBeenCalledWith({
reason: "post-compaction",
force: true,
progress: undefined,
});
expect(runSafeReindex).not.toHaveBeenCalled();
expect(result).toEqual({ handled: true, sessionsDirty: true });
expect(sessionsDirtyFiles.has("/tmp/targeted-fallback.jsonl")).toBe(true);
expect(sessionsDirtyFiles.has("/tmp/other-dirty.jsonl")).toBe(true);
});
});

View File

@@ -22,12 +22,23 @@ export function clearMemorySyncedSessionFiles(params: {
return params.sessionsDirtyFiles.size > 0;
}
export function markMemoryTargetSessionFilesDirty(params: {
sessionsDirtyFiles: Set<string>;
targetSessionFiles?: Iterable<string> | null;
}): boolean {
if (params.targetSessionFiles) {
for (const targetSessionFile of params.targetSessionFiles) {
params.sessionsDirtyFiles.add(targetSessionFile);
}
}
return params.sessionsDirtyFiles.size > 0;
}
export async function runMemoryTargetedSessionSync(params: {
hasSessionSource: boolean;
targetSessionFiles: Set<string> | null;
reason?: string;
progress?: TargetedSyncProgress;
useUnsafeReindex: boolean;
sessionsDirtyFiles: Set<string>;
syncSessionFiles: (params: {
needsFullReindex: boolean;
@@ -36,16 +47,6 @@ export async function runMemoryTargetedSessionSync(params: {
}) => Promise<void>;
shouldFallbackOnError: (err: unknown) => boolean;
activateFallbackProvider: (reason: string) => Promise<boolean>;
runSafeReindex: (params: {
reason?: string;
force?: boolean;
progress?: TargetedSyncProgress;
}) => Promise<void>;
runUnsafeReindex: (params: {
reason?: string;
force?: boolean;
progress?: TargetedSyncProgress;
}) => Promise<void>;
}): Promise<{ handled: boolean; sessionsDirty: boolean }> {
if (!params.hasSessionSource || !params.targetSessionFiles) {
return {
@@ -74,19 +75,12 @@ export async function runMemoryTargetedSessionSync(params: {
if (!activated) {
throw err;
}
const reindexParams = {
reason: params.reason,
force: true,
progress: params.progress,
};
if (params.useUnsafeReindex) {
await params.runUnsafeReindex(reindexParams);
} else {
await params.runSafeReindex(reindexParams);
}
return {
handled: true,
sessionsDirty: params.sessionsDirtyFiles.size > 0,
sessionsDirty: markMemoryTargetSessionFilesDirty({
sessionsDirtyFiles: params.sessionsDirtyFiles,
targetSessionFiles: params.targetSessionFiles,
}),
};
}
}

View File

@@ -15,6 +15,7 @@ vi.mock("./embeddings.js", () => ({
provider: null,
providerUnavailableReason: "No embeddings provider available.",
}),
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
resolveEmbeddingProviderFallbackModel: () => "fts-only",
}));

View File

@@ -47,6 +47,7 @@ import {
resolveMemoryProviderState,
type MemoryProviderLifecycleState,
} from "./manager-provider-state.js";
import type { MemoryIndexIdentityState } from "./manager-reindex-state.js";
import { resolveMemorySearchPreflight } from "./manager-search-preflight.js";
import { searchKeyword, searchVector } from "./manager-search.js";
import {
@@ -171,6 +172,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
protected override sessionsDirty = false;
protected override sessionsDirtyFiles = new Set<string>();
protected override sessionPendingFiles = new Set<string>();
private indexIdentityDirty = false;
protected override sessionDeltas = new Map<
string,
{ lastSize: number; pendingBytes: number; pendingMessages: number }
@@ -183,6 +185,10 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
private readonlyRecoverySuccesses = 0;
private readonlyRecoveryFailures = 0;
private readonlyRecoveryLastError?: string;
private indexIdentityState: MemoryIndexIdentityState = {
status: "missing",
reason: "index metadata is missing",
};
private static async loadProviderResult(params: {
cfg: OpenClawConfig;
@@ -267,6 +273,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (meta?.vectorDims) {
this.vector.dims = meta.vectorDims;
}
const initialIndexIdentity = this.resolveCurrentIndexIdentityState({
meta,
providerKeyKnown: Boolean(params.providerResult),
});
this.indexIdentityState = initialIndexIdentity;
this.indexIdentityDirty =
initialIndexIdentity.status === "mismatched" ||
(initialIndexIdentity.status === "missing" && this.sources.has("memory"));
const transient = params.purpose === "status" || params.purpose === "cli";
if (!transient) {
this.ensureWatcher();
@@ -377,6 +391,23 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
}
private refreshIndexIdentityDirty(params?: { providerKeyKnown?: boolean }) {
const provider = this.providerInitialized
? this.provider
? { id: this.provider.id, model: this.provider.model }
: null
: undefined;
const state = this.resolveCurrentIndexIdentityState({
...(provider !== undefined ? { provider } : {}),
providerKeyKnown: params?.providerKeyKnown,
});
this.indexIdentityState = state;
this.indexIdentityDirty =
state.status === "mismatched" ||
(state.status === "missing" && (this.sources.has("memory") || this.hasIndexedChunks()));
return state;
}
async search(
query: string,
opts?: {
@@ -423,6 +454,27 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (preflight.shouldInitializeProvider) {
await this.ensureProviderInitialized();
}
if (!this.provider && this.providerLifecycle.mode === "degraded") {
const activatedFallback = await this.activateFallbackProvider(
this.providerLifecycle.reason,
).catch((fallbackErr: unknown) => {
log.warn(
`memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`,
);
return false;
});
if (activatedFallback) {
this.refreshIndexIdentityDirty({
providerKeyKnown: this.providerInitialized,
});
}
}
const indexIdentity = this.refreshIndexIdentityDirty({
providerKeyKnown: this.providerInitialized,
});
if (indexIdentity.status !== "valid") {
return [];
}
const minScore = opts?.minScore ?? this.settings.query.minScore;
const maxResults = opts?.maxResults ?? this.settings.query.maxResults;
const searchSources =
@@ -443,20 +495,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
Math.max(1, Math.floor(maxResults * hybrid.candidateMultiplier)),
);
if (!this.provider && this.providerLifecycle.mode === "degraded") {
const activatedFallback = await this.activateFallbackProvider(
this.providerLifecycle.reason,
).catch((fallbackErr: unknown) => {
log.warn(
`memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`,
);
return false;
});
if (activatedFallback) {
await this.runSafeReindex({ reason: "fallback", force: true });
}
}
// FTS-only mode: no embedding provider available
if (!this.provider) {
if (!this.fts.enabled || !this.fts.available) {
@@ -552,7 +590,13 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
})
: false;
if (activatedFallback) {
await this.runSafeReindex({ reason: "fallback", force: true });
if (
this.refreshIndexIdentityDirty({
providerKeyKnown: this.providerInitialized,
}).status !== "valid"
) {
return [];
}
keywordResults = await loadKeywordResults();
queryVec = await this.embedQueryWithRetry(cleaned);
} else if (!this.provider && this.fts.enabled && this.fts.available) {
@@ -856,6 +900,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
status(): MemoryProviderStatus {
this.refreshIndexIdentityDirty({
providerKeyKnown: this.providerInitialized,
});
const sourceFilter = this.buildSourceFilter();
const aggregateState = collectMemoryStatusAggregate({
db: {
@@ -884,7 +931,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
backend: "builtin",
files: aggregateState.files,
chunks: aggregateState.chunks,
dirty: this.dirty || this.sessionsDirty,
dirty: this.dirty || this.sessionsDirty || this.indexIdentityDirty,
workspaceDir: this.workspaceDir,
dbPath: this.settings.store.path,
provider: providerInfo.provider,
@@ -937,6 +984,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
searchMode: providerInfo.searchMode,
providerState: this.providerLifecycle,
providerUnavailableReason: this.providerUnavailableReason,
indexIdentity: this.indexIdentityState,
readonlyRecovery: {
attempts: this.readonlyRecoveryAttempts,
successes: this.readonlyRecoverySuccesses,

View File

@@ -126,6 +126,7 @@ vi.mock("./sqlite-vec.js", () => ({
}));
vi.mock("./embeddings.js", () => ({
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {

View File

@@ -117,15 +117,25 @@ export function createMemoryTool(params: {
};
}
export function buildMemorySearchUnavailableResult(error: string | undefined) {
export function buildMemorySearchUnavailableResult(
error: string | undefined,
overrides?: {
warning?: string;
action?: string;
},
) {
const reason = (error ?? "memory search unavailable").trim() || "memory search unavailable";
const isQuotaError = /insufficient_quota|quota|429/.test(normalizeLowercaseStringOrEmpty(reason));
const warning = isQuotaError
? "Memory search is unavailable because the embedding provider quota is exhausted."
: "Memory search is unavailable due to an embedding/provider error.";
const action = isQuotaError
? "Top up or switch embedding provider, then retry memory_search."
: "Check embedding provider configuration and retry memory_search.";
const warning =
overrides?.warning ??
(isQuotaError
? "Memory search is unavailable because the embedding provider quota is exhausted."
: "Memory search is unavailable due to an embedding/provider error.");
const action =
overrides?.action ??
(isQuotaError
? "Top up or switch embedding provider, then retry memory_search."
: "Check embedding provider configuration and retry memory_search.");
return {
results: [],
disabled: true,

View File

@@ -3,8 +3,10 @@ import {
getMemorySearchManagerMockCalls,
getMemorySearchManagerMockConfigs,
getMemorySearchManagerMockParams,
getMemorySyncMockCalls,
resetMemoryToolMockState,
setMemoryBackend,
setMemoryCustomStatus,
setMemorySearchImpl,
setMemorySearchManagerImpl,
} from "./memory-tool-manager-mock.js";
@@ -256,6 +258,39 @@ describe("memory_search unavailable payloads", () => {
expect(searchCalls).toBe(2);
});
it("returns unavailable metadata when the index identity is paused", async () => {
let searchCalls = 0;
setMemorySearchImpl(async () => {
searchCalls += 1;
return [];
});
const reason = "index was built for provider openai, expected ollama";
setMemoryCustomStatus({
indexIdentity: {
status: "mismatched",
reason,
},
});
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
},
});
const result = await tool.execute("paused-index", { query: "hidden thread codename" });
expectUnavailableMemorySearchDetails(result.details, {
error: reason,
warning:
"Tell the user: memory search is paused because the memory index was built with a different embedding provider/model/settings.",
action:
"Tell the user to run: openclaw memory status --index or openclaw memory index --force.",
});
expect(searchCalls).toBe(1);
expect(getMemorySyncMockCalls()).toBe(0);
});
it("returns structured search debug metadata for qmd results", async () => {
setMemoryBackend("qmd");
setMemorySearchImpl(async (opts) => {

View File

@@ -18,6 +18,7 @@ import {
resolveMemoryDreamingConfig,
resolveMemoryDeepDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { asRecord } from "./dreaming-shared.js";
import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";
import {
@@ -109,6 +110,28 @@ async function runMemorySearchToolWithDeadline<T>(params: {
}
}
const PAUSED_MEMORY_INDEX_WARNING =
"Tell the user: memory search is paused because the memory index was built with a different embedding provider/model/settings.";
const PAUSED_MEMORY_INDEX_ACTION =
"Tell the user to run: openclaw memory status --index or openclaw memory index --force.";
function resolvePausedMemoryIndexIdentityReason(status: { custom?: unknown }): string | undefined {
const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity);
if (indexIdentity?.status !== "mismatched" && indexIdentity?.status !== "missing") {
return undefined;
}
return typeof indexIdentity.reason === "string" && indexIdentity.reason.trim()
? indexIdentity.reason.trim()
: "memory index identity is missing or mismatched";
}
function buildPausedMemoryIndexUnavailableResult(reason: string) {
return buildMemorySearchUnavailableResult(reason, {
warning: PAUSED_MEMORY_INDEX_WARNING,
action: PAUSED_MEMORY_INDEX_ACTION,
});
}
function sortMemorySearchToolResults<T extends { score: number; path: string }>(results: T[]): T[] {
return results.toSorted((left, right) => {
if (left.score !== right.score) {
@@ -316,7 +339,7 @@ export function createMemorySearchTool(options: {
label: "Memory Search",
name: "memory_search",
description:
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.",
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable; you must tell the user and include the warning/action guidance.",
parameters: MemorySearchSchema,
execute:
({ cfg, agentId }) =>
@@ -400,6 +423,7 @@ export function createMemorySearchTool(options: {
let model: string | undefined;
let fallback: unknown;
let searchMode: string | undefined;
let pausedIndexIdentityReason: string | undefined;
let searchDebug:
| {
backend: string;
@@ -447,9 +471,21 @@ export function createMemorySearchTool(options: {
activeMemory = refreshed;
rawResults = await activeMemory.manager.search(query, searchOptions);
}
const statusBeforeRetry = activeMemory.manager.status();
pausedIndexIdentityReason =
resolvePausedMemoryIndexIdentityReason(statusBeforeRetry);
if (pausedIndexIdentityReason) {
return;
}
if (rawResults.length === 0 && activeMemory.manager.sync) {
await activeMemory.manager.sync({ reason: "search", force: true });
rawResults = await activeMemory.manager.search(query, searchOptions);
pausedIndexIdentityReason = resolvePausedMemoryIndexIdentityReason(
activeMemory.manager.status(),
);
if (pausedIndexIdentityReason) {
return;
}
}
rawResults = await filterMemorySearchHitsBySessionVisibility({
cfg,
@@ -500,6 +536,11 @@ export function createMemorySearchTool(options: {
hits: rawResults.length,
};
});
if (pausedIndexIdentityReason) {
return jsonResult(
buildPausedMemoryIndexUnavailableResult(pausedIndexIdentityReason),
);
}
}
const supplementResults = shouldQuerySupplements
? await runUnavailablePhase(

View File

@@ -3,7 +3,9 @@ import {
canonicalizeCodexResponsesBaseUrl,
isOpenAIApiBaseUrl,
isOpenAICodexBaseUrl,
OPENAI_API_BASE_URL,
OPENAI_CODEX_RESPONSES_BASE_URL,
resolveOpenAIDefaultBaseUrl,
} from "./base-url.js";
describe("openai base URL helpers", () => {
@@ -57,4 +59,12 @@ describe("openai base URL helpers", () => {
);
expect(canonicalizeCodexResponsesBaseUrl(undefined)).toBeUndefined();
});
it("resolves default API base URL from OPENAI_BASE_URL", () => {
expect(resolveOpenAIDefaultBaseUrl({})).toBe(OPENAI_API_BASE_URL);
expect(resolveOpenAIDefaultBaseUrl({ OPENAI_BASE_URL: " " })).toBe(OPENAI_API_BASE_URL);
expect(resolveOpenAIDefaultBaseUrl({ OPENAI_BASE_URL: " https://proxy.example/v1 " })).toBe(
"https://proxy.example/v1",
);
});
});

View File

@@ -1,6 +1,13 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export const OPENAI_CODEX_RESPONSES_BASE_URL = "https://chatgpt.com/backend-api/codex";
export const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
export function resolveOpenAIDefaultBaseUrl(
env: Record<string, string | undefined> = process.env,
): string {
return normalizeOptionalString(env.OPENAI_BASE_URL) ?? OPENAI_API_BASE_URL;
}
export function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
const trimmed = normalizeOptionalString(baseUrl);

View File

@@ -11,7 +11,11 @@ import {
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OPENAI_ACCOUNT_WIZARD_GROUP, OPENAI_API_KEY_LABEL } from "./auth-choice-copy.js";
import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl } from "./base-url.js";
import {
isOpenAIApiBaseUrl,
isOpenAICodexBaseUrl,
resolveOpenAIDefaultBaseUrl,
} from "./base-url.js";
import { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "./default-models.js";
import {
buildOpenAIChatGPTAuthMethods,
@@ -203,7 +207,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: "https://api.openai.com/v1",
baseUrl: resolveOpenAIDefaultBaseUrl(),
reasoning: false,
input: ["text", "image"],
cost: OPENAI_CHAT_LATEST_COST,
@@ -215,7 +219,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: "https://api.openai.com/v1",
baseUrl: resolveOpenAIDefaultBaseUrl(),
reasoning: true,
input: ["text", "image"],
mediaInput: OPENAI_GPT_55_MEDIA_INPUT,
@@ -229,7 +233,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: "https://api.openai.com/v1",
baseUrl: resolveOpenAIDefaultBaseUrl(),
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_55_PRO_COST,
@@ -241,7 +245,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: "https://api.openai.com/v1",
baseUrl: resolveOpenAIDefaultBaseUrl(),
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_54_COST,
@@ -253,7 +257,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: "https://api.openai.com/v1",
baseUrl: resolveOpenAIDefaultBaseUrl(),
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_54_PRO_COST,
@@ -265,7 +269,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: "https://api.openai.com/v1",
baseUrl: resolveOpenAIDefaultBaseUrl(),
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_54_MINI_COST,
@@ -277,7 +281,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
patch = {
api: "openai-responses",
provider: PROVIDER_ID,
baseUrl: "https://api.openai.com/v1",
baseUrl: resolveOpenAIDefaultBaseUrl(),
reasoning: true,
input: ["text", "image"],
cost: OPENAI_GPT_54_NANO_COST,

View File

@@ -42,6 +42,10 @@ describe("QQBot token manager", () => {
url: "https://bots.qq.com/app/getAppAccessToken",
auditContext: "qqbot-token",
capture: false,
policy: {
hostnameAllowlist: ["bots.qq.com"],
allowRfc2544BenchmarkRange: true,
},
init: {
method: "POST",
headers: {
@@ -54,6 +58,25 @@ describe("QQBot token manager", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("passes the RFC2544 SSRF allowance to the token fetch (regression for #88984)", async () => {
mockGuardedTokenResponse('{"access_token":"token-1","expires_in":7200}', {
status: 200,
headers: { "content-type": "application/json" },
});
await expect(new TokenManager().getAccessToken("app-id", "secret")).resolves.toBe("token-1");
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://bots.qq.com/app/getAppAccessToken",
auditContext: "qqbot-token",
policy: {
hostnameAllowlist: ["bots.qq.com"],
allowRfc2544BenchmarkRange: true,
},
}),
);
});
it("does not cache access tokens forever when expires_in is unsafe", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));

View File

@@ -12,13 +12,31 @@ import {
resolveExpiresAtMsFromDurationSeconds,
resolveTimestampMsToIsoString,
} from "openclaw/plugin-sdk/number-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import type { EngineLogger } from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 7200;
/**
* Host-scoped SSRF policy for the QQ Bot token endpoint.
*
* `TOKEN_URL` is a hard-coded `https://bots.qq.com/...` constant, so this
* relaxation only ever applies to that single host. Fake-IP proxy stacks
* (sing-box, Clash, Surge, WSL2 DNS, etc.) routinely map `bots.qq.com` into
* the RFC 2544 benchmark range `198.18.0.0/15`, which the default SSRF
* guard blocks. We mirror the existing media-path pattern
* (`QQBOT_MEDIA_SSRF_POLICY` in `../utils/file-utils.ts`) so the relaxation
* stays narrowly host-scoped instead of weakening the global default.
*
* See https://github.com/openclaw/openclaw/issues/88984.
*/
const QQBOT_TOKEN_SSRF_POLICY: SsrFPolicy = {
hostnameAllowlist: ["bots.qq.com"],
allowRfc2544BenchmarkRange: true,
};
interface CachedToken {
token: string;
expiresAt: number;
@@ -234,6 +252,7 @@ export class TokenManager {
url: TOKEN_URL,
auditContext: "qqbot-token",
capture: false,
policy: QQBOT_TOKEN_SSRF_POLICY,
init: {
method: "POST",
headers: {

View File

@@ -0,0 +1,74 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "./data-paths.js";
const createdStateDirs: string[] = [];
function createTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
createdStateDirs.push(dir);
return dir;
}
describe("qqbot credential backup paths", () => {
afterEach(() => {
vi.unstubAllEnvs();
for (const stateDir of createdStateDirs.splice(0)) {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("scopes credential backups to the active OPENCLAW_STATE_DIR", () => {
const stateDir = createTempDir("qqbot-state-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
expect(getCredentialBackupFile("default")).toBe(
path.join(stateDir, "qqbot", "data", "credential-backup-default.json"),
);
expect(getLegacyCredentialBackupFile()).toBe(
path.join(stateDir, "qqbot", "data", "credential-backup.json"),
);
});
it("keeps same account IDs isolated across different state directories", () => {
const stateDirA = createTempDir("qqbot-state-a-");
const stateDirB = createTempDir("qqbot-state-b-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDirA);
const gatewayAPath = getCredentialBackupFile("default");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDirB);
const gatewayBPath = getCredentialBackupFile("default");
expect(gatewayAPath).toBe(
path.join(stateDirA, "qqbot", "data", "credential-backup-default.json"),
);
expect(gatewayBPath).toBe(
path.join(stateDirB, "qqbot", "data", "credential-backup-default.json"),
);
expect(gatewayBPath).not.toBe(gatewayAPath);
});
it("uses OPENCLAW_HOME for default credential backup state", () => {
const homeDir = createTempDir("qqbot-openclaw-home-");
vi.stubEnv("OPENCLAW_STATE_DIR", "");
vi.stubEnv("OPENCLAW_HOME", homeDir);
expect(getCredentialBackupFile("default")).toBe(
path.join(homeDir, ".openclaw", "qqbot", "data", "credential-backup-default.json"),
);
});
it("expands tilde state-dir overrides through the canonical state resolver", () => {
const homeDir = createTempDir("qqbot-home-");
vi.stubEnv("HOME", homeDir);
vi.stubEnv("OPENCLAW_HOME", "");
vi.stubEnv("OPENCLAW_STATE_DIR", "~/gateway-a");
expect(getCredentialBackupFile("default")).toBe(
path.join(homeDir, "gateway-a", "qqbot", "data", "credential-backup-default.json"),
);
});
});

View File

@@ -11,7 +11,7 @@
*/
import path from "node:path";
import { getQQBotDataPath } from "./platform.js";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
/**
* Normalise an identifier so it is safe to embed in a filename.
@@ -21,6 +21,10 @@ function safeName(id: string): string {
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
}
function getCredentialBackupRoot(): string {
return path.join(resolveStateDir(process.env), "qqbot", "data");
}
// ---- credential backup ----
/**
@@ -29,10 +33,10 @@ function safeName(id: string): string {
* missing from the live config.
*/
export function getCredentialBackupFile(accountId: string): string {
return path.join(getQQBotDataPath("data"), `credential-backup-${safeName(accountId)}.json`);
return path.join(getCredentialBackupRoot(), `credential-backup-${safeName(accountId)}.json`);
}
/** Legacy single-file credential backup (pre-multi-account-isolation). */
export function getLegacyCredentialBackupFile(): string {
return path.join(getQQBotDataPath("data"), "credential-backup.json");
return path.join(getCredentialBackupRoot(), "credential-backup.json");
}

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