diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 66a924ee..c8794b72 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -37,6 +37,20 @@ permissions:
contents: read
jobs:
+ release-documentation:
+ name: Release Documentation
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ persist-credentials: false
+
+ - name: Reject stale release availability language
+ if: github.ref_type == 'tag' || (github.event_name == 'workflow_dispatch' && inputs.create_release)
+ env:
+ RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
+ run: python3 eng/verify-release-docs.py "$RELEASE_TAG"
+
public-compatibility-smoke:
permissions:
contents: read
@@ -86,7 +100,10 @@ jobs:
build-release-assets:
name: Build ${{ matrix.asset }} assets
- needs: public-compatibility-smoke
+ needs:
+ - release-documentation
+ - public-compatibility-smoke
+ - desktop-first-success
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -235,6 +252,11 @@ jobs:
& $cliBin version
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ if ($IsLinux) {
+ python3 "$root/eng/verify-desktop-first-success.py" --cli $cliBin --gateway $gatewayBin
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
+
$desktopArchive = Join-Path $archives "openclaw-desktop-$rid.zip"
$gatewayArchive = Join-Path $archives "openclaw-gateway-aot-$rid.zip"
$cliArchive = Join-Path $archives "openclaw-cli-aot-$rid.zip"
@@ -282,6 +304,47 @@ jobs:
name: release-assets-${{ matrix.rid }}
path: artifacts/release-archives/*
+ desktop-first-success:
+ name: Desktop First-Success Contract
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ persist-credentials: false
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Cache NuGet packages
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
+ with:
+ path: ${{ env.NUGET_PACKAGES }}
+ key: nuget-${{ runner.os }}-${{ hashFiles('OpenClaw.Net.slnx', '**/*.csproj', '**/*.props', '**/*.targets') }}
+ restore-keys: |
+ nuget-${{ runner.os }}-
+
+ - name: Validate packaged smoke fixture
+ run: python3 -m unittest discover -s eng -p "test_verify_desktop_first_success.py"
+
+ - name: Restore desktop contract tests
+ run: dotnet restore src/OpenClaw.Tests
+
+ - name: Build desktop contract tests
+ run: dotnet build --no-restore -c Release src/OpenClaw.Tests ${{ env.FAST_BUILD_PROPERTIES }}
+
+ - name: Run local-agent first-success contract
+ run: dotnet test --no-build -c Release --filter "Category=DesktopRelease" --verbosity normal --logger "trx;LogFileName=desktop-first-success.trx" src/OpenClaw.Tests
+
+ - name: Upload desktop contract results
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: desktop-first-success-results
+ path: "**/desktop-first-success.trx"
+
publish-github-release:
name: Publish GitHub Release
needs: build-release-assets
@@ -327,9 +390,9 @@ jobs:
- openclaw-desktop-osx-arm64.zip
- openclaw-desktop-linux-x64.zip
- These archives bundle Companion, the NativeAOT gateway, and the NativeAOT CLI. Standalone gateway and CLI archives are also attached for operators.
+ These archives bundle AgentQi Companion, the NativeAOT gateway, and the NativeAOT CLI. Standalone gateway and CLI archives are also attached for operators.
- Release verification for this exact tag includes the deterministic pinned public-plugin compatibility gate. The moving latest-package canary is reported separately and is not release-blocking.
+ Release verification for this exact tag includes the deterministic pinned public-plugin compatibility gate, the AgentQi Companion local-agent first-success contract, and the release-documentation availability check. The moving latest-package canary is reported separately and is not release-blocking.
Windows and macOS assets are currently unsigned unless this workflow is extended with signing secrets. See docs/RELEASES.md for the expected first-run warnings and the signing path.
EOF
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60993e7a..42a3950c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ All notable changes to this project are tracked in this file.
### Release Engineering
+- Standardized the desktop product name as AgentQi Companion while preserving OpenClaw.NET runtime, package, protocol, and storage identities.
+- Added a release-blocking desktop first-success contract covering Companion preset persistence, shared preset capabilities, sequential multi-tool selection, and an Ollama-compatible tool round trip.
+- Added a release documentation gate that rejects tags still described as future or unavailable work, and updated v0.3.0 availability guidance.
- Made the deterministic pinned public-plugin compatibility smoke run on pull requests and `main` pushes, while keeping the moving latest-package canary scheduled/manual and non-blocking.
- Added the pinned public-plugin compatibility smoke as a prerequisite for release asset builds.
- Pinned Supermemory's complete plugin/peer test set and kept a separate latest-peer canary so release verification is reproducible while upstream drift remains visible.
diff --git a/README.md b/README.md
index 8c7bc0c5..b8b56566 100644
--- a/README.md
+++ b/README.md
@@ -15,18 +15,18 @@
Run an assistant locally or host a gateway for your team. Connect a hosted or local model, give it tools and memory, and work through desktop chat, the browser, messaging channels, or APIs. Developers can extend the runtime in .NET and deploy supported capabilities with NativeAOT.
-**OpenClaw.NET** is the runtime and repository. **AgentQi [OpenClaw.NET]** is the Avalonia desktop companion; [AgentQi.dev](https://agentqi.dev) is the documentation and ecosystem home.
+**OpenClaw.NET** is the runtime and repository. **AgentQi Companion** is the Avalonia desktop product for OpenClaw.NET; [AgentQi.dev](https://agentqi.dev) is the documentation and ecosystem home.
This is an independent .NET implementation inspired by [OpenClaw](https://github.com/openclaw/openclaw), with practical ecosystem compatibility. It is not affiliated with or endorsed by the upstream project.
## Meet the desktop companion
-Chat is the starting point. AgentQi brings setup, connections, activity, memory, history, and settings into one desktop interface, with light and dark themes.
+Chat is the starting point. AgentQi Companion brings setup, connections, activity, memory, history, and settings into one desktop interface, with light and dark themes.
-
+
*Companion welcome screen from `main`, before connecting a gateway. Packaged releases may show an earlier interface.*
@@ -39,7 +39,7 @@ Chat configuration covers supported scalar settings, not every operation. Creden
## Download And Run Desktop
-Choose a desktop bundle from the [latest release](https://github.com/clawdotnet/openclaw.net/releases/latest). Each includes Companion, the NativeAOT gateway, and the NativeAOT CLI.
+Choose a desktop bundle from the [latest release](https://github.com/clawdotnet/openclaw.net/releases/latest). Each includes AgentQi Companion, the NativeAOT gateway, and the NativeAOT CLI.
| Platform | Download |
| --- | --- |
@@ -119,7 +119,7 @@ Tool: echo(hello): ok
| Area | Capabilities |
| --- | --- |
| Agent runtime | Streaming, tool execution, cancellation, retries, sessions, memory, and token-usage reporting. |
-| Interfaces | AgentQi desktop companion, browser chat and admin UI, CLI, terminal UI, OpenAI-compatible HTTP endpoints, MCP, and WebSockets. |
+| Interfaces | AgentQi Companion, browser chat and admin UI, CLI, terminal UI, OpenAI-compatible HTTP endpoints, MCP, and WebSockets. |
| Models | OpenAI, Claude, Gemini, Azure OpenAI, DeepSeek, Ollama, and OpenAI-compatible providers; named profiles and optional embedded local inference. |
| Tools and channels | 80+ native and optional tool surfaces for files, web, sessions, databases, email, home automation, and more; adapters for Telegram, WhatsApp, Teams, Slack, Discord, and other channels. The active set depends on configuration. |
| Extensions | MCP servers and interactive MCP Apps, reusable `SKILL.md` packages, first-party .NET integrations, and supported OpenClaw TS/JS plugins. |
diff --git a/docs/ARCHITECTURE_BOUNDARIES.md b/docs/ARCHITECTURE_BOUNDARIES.md
index cc783645..70fa46d7 100644
--- a/docs/ARCHITECTURE_BOUNDARIES.md
+++ b/docs/ARCHITECTURE_BOUNDARIES.md
@@ -8,9 +8,19 @@ OpenClaw.NET is a NativeAOT-friendly agent runtime and gateway for local and sel
| --- | --- | --- |
| **OpenClaw.NET** | Repository and runtime identity | Owns runtime correctness, local/self-hosted execution, gateway and CLI primitives, durable state, recovery, replay, and plugin execution compatibility. |
| **AgentQi** | Documentation and ecosystem umbrella | Owns the broader developer/operator experience, ecosystem discovery, trust and lifecycle UX, and future multi-instance or fleet-level management. |
+| **AgentQi Companion** | Desktop product for OpenClaw.NET | Owns the chat-first desktop experience for setup and operation of one OpenClaw.NET runtime. “For OpenClaw.NET” describes the relationship; it is not a package, protocol, or storage rename. |
| **AgentQiX** | Reserved likely future runtime identity | No current package, repository, or runtime rename is implied. A migration requires an explicit naming decision, compatibility plan, and release boundary. |
-The current rule is therefore to keep runtime capabilities in OpenClaw.NET and describe cross-runtime ecosystem or managed-product work under AgentQi. Do not introduce AgentQiX naming into runtime packages or user-facing migration guidance until that separate decision is made.
+The current rule is therefore to keep runtime capabilities in OpenClaw.NET, use AgentQi Companion for the desktop application, and describe cross-runtime ecosystem or managed-product work under AgentQi. Do not introduce AgentQiX naming into runtime packages or user-facing migration guidance until that separate decision is made.
+
+### Naming surfaces
+
+| Surface | Canonical label |
+| --- | --- |
+| Repository, runtime, gateway, CLI, packages, protocols, settings, and GitHub release titles | **OpenClaw.NET** |
+| Desktop window chrome, application metadata, desktop-bundle descriptions, and desktop documentation | **AgentQi Companion**; use **AgentQi Companion for OpenClaw.NET** when the relationship needs explanation |
+| Documentation and ecosystem navigation | **AgentQi** and **AgentQi.dev** |
+| Future runtime references | Keep **AgentQiX** reserved until an explicit migration decision and compatibility plan exist |
## OpenClaw.NET Core
diff --git a/docs/RELEASES.md b/docs/RELEASES.md
index 53940f32..5a0dc066 100644
--- a/docs/RELEASES.md
+++ b/docs/RELEASES.md
@@ -2,7 +2,7 @@
OpenClaw.NET's low-friction desktop path is the **desktop bundle** published on [GitHub Releases](https://github.com/clawdotnet/openclaw.net/releases/latest). It bundles:
-- Companion
+- AgentQi Companion
- the NativeAOT gateway
- the NativeAOT CLI
@@ -74,7 +74,7 @@ A release is ready only when all of the following are true for the exact tag com
Use **full CI** only when all required lanes above passed for that exact commit. Report the non-blocking latest-package canary separately so upstream drift is visible without making a moving dependency a release gate.
-> **v0.2.0 verification note:** v0.2.0 was published before the pinned public compatibility job became a release-workflow dependency. Its successful build and platform smokes did not establish public-plugin compatibility. The reliability and recovery work currently marked **main only** in the [roadmap](ROADMAP.md) also landed after the v0.2.0 tag.
+> **v0.2.0 verification note:** v0.2.0 was published before the pinned public compatibility job became a release-workflow dependency. Its successful build and platform smokes did not establish public-plugin compatibility. The reliability and recovery work documented in the [roadmap](ROADMAP.md) landed after v0.2.0 and was first released in v0.3.0.
The workflow currently builds:
@@ -84,7 +84,7 @@ The workflow currently builds:
The macOS runner label is intentionally ARM-native for the `osx-arm64` artifact. Add an Intel macOS row only if you want to support older Intel Macs and have a runner that can NativeAOT publish that RID reliably.
-### Companion Release Smoke
+### AgentQi Companion Release Smoke
Before publishing a public desktop release, run this manual smoke on at least one desktop bundle:
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index d214ed7a..afea6ff4 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -2,15 +2,15 @@
## Release Availability
-The roadmap describes the current `main` branch, not necessarily the latest published binaries. [v0.2.0](https://github.com/clawdotnet/openclaw.net/tree/v0.2.0) was cut before the reliability integration listed below. Items marked **main only** target **v0.3.0** and are not available in v0.2.0. A v0.2.1 release, if needed, is reserved for corrective release or compatibility work rather than this feature set.
+The roadmap describes the current `main` branch, which may move ahead of published binaries. The reliability, recovery, and AgentQi Companion work listed as recently completed is available in [v0.3.0](https://github.com/clawdotnet/openclaw.net/tree/v0.3.0) and later. Consult each future item for an explicit target release rather than assuming that `main` is already packaged.
## Recently Completed
-- **Companion token protection and migration** (**main only; target v0.3.0**): OS-backed token storage now automatically migrates legacy JSON/fallback credentials with read-back verification, preserves recoverable copies on failure, respects Remember token and plaintext opt-in, and uses atomic private file writes. See [Companion token storage](companion-token-storage.md).
+- **Companion token protection and migration** (**released in v0.3.0**): OS-backed token storage now automatically migrates legacy JSON/fallback credentials with read-back verification, preserves recoverable copies on failure, respects Remember token and plaintext opt-in, and uses atomic private file writes. See [Companion token storage](companion-token-storage.md).
-- **Run explanation and recovery view** (**main only; target v0.3.0**): admin console, Dashboard, and Companion session details combine recorded run/goal state, goal notes, session-scoped pending approvals, checkpoints, and recent tool failure evidence with contextual recovery guidance. Existing timelines remain available for investigation; the view does not authorize or replay actions.
+- **Run explanation and recovery view** (**released in v0.3.0**): admin console, Dashboard, and Companion session details combine recorded run/goal state, goal notes, session-scoped pending approvals, checkpoints, and recent tool failure evidence with contextual recovery guidance. Existing timelines remain available for investigation; the view does not authorize or replay actions.
-- **Real-run regression import and offline replay** (**main only; target v0.3.0**): import a redacted, complete text exchange from a gateway trajectory export and run recorded provider/tool fixtures through `RuntimeScenarioRunner`, with independent assertions and strict consumption checks. Includes an executable sample; see [trajectory replay](testing/trajectory-replay.md).
+- **Real-run regression import and offline replay** (**released in v0.3.0**): import a redacted, complete text exchange from a gateway trajectory export and run recorded provider/tool fixtures through `RuntimeScenarioRunner`, with independent assertions and strict consumption checks. Includes an executable sample; see [trajectory replay](testing/trajectory-replay.md).
- Browser sessions invalidate after local operator account updates, deletion, or disablement.
- Goal completion accepts completed tool work; model status updates run alone and blocked transitions require three observations. Resume resets continuation and blocker counters.
@@ -70,10 +70,10 @@ These are strong candidates for the next roadmap phases because they extend the
The runtime already includes CLI insights, URL safety validation, and trajectory export. The next additions build on those capabilities:
-1. **Durable action reconciliation (main only, opt-in; target v0.3.0)**: persisted dispatch journal, stable provider adapter keys, completed-result reuse, and blocking of unknown outcomes before replay. See [durable actions](durable-actions.md). Provider-specific adapters and executor-bypassing jobs need individual integration.
-2. **Expanded regression capture (main only; target v0.3.0)**: opt-in bounded automatic capture, structured failed/blocked tool replay, and offline multimodal URL-content verification. See [trajectory replay](testing/trajectory-replay.md).
-3. **Full-instance backup and restore (main only; target v0.3.0)**: offline inventory plans capture durable state and secret-reference manifests, verify checksums, and restore into a new isolated directory with SQLite validation and no dispatch. See [instance backup](instance-backup.md).
-4. **Guided recovery controls (main only; target v0.3.0)**: permission-aware goal pause/resume and evidence-backed action reconciliation with revision, approval, and budget checks. Complements the run explanation view; see [guided recovery](guided-recovery.md).
+1. **Durable action reconciliation (released in v0.3.0, opt-in)**: persisted dispatch journal, stable provider adapter keys, completed-result reuse, and blocking of unknown outcomes before replay. See [durable actions](durable-actions.md). Provider-specific adapters and executor-bypassing jobs need individual integration.
+2. **Expanded regression capture (released in v0.3.0)**: opt-in bounded automatic capture, structured failed/blocked tool replay, and offline multimodal URL-content verification. See [trajectory replay](testing/trajectory-replay.md).
+3. **Full-instance backup and restore (released in v0.3.0)**: offline inventory plans capture durable state and secret-reference manifests, verify checksums, and restore into a new isolated directory with SQLite validation and no dispatch. See [instance backup](instance-backup.md).
+4. **Guided recovery controls (released in v0.3.0)**: permission-aware goal pause/resume and evidence-backed action reconciliation with revision, approval, and budget checks. Complements the run explanation view; see [guided recovery](guided-recovery.md).
## Security Hardening (Likely Breaking)
diff --git a/docs/RUN_RECOVERY.md b/docs/RUN_RECOVERY.md
index db3b9d58..0f19e05f 100644
--- a/docs/RUN_RECOVERY.md
+++ b/docs/RUN_RECOVERY.md
@@ -1,6 +1,6 @@
# Run explanation and recovery
-> **Release availability:** Available on `main` and targeted for v0.3.0; not included in v0.2.0. See the [roadmap](ROADMAP.md).
+> **Release availability:** Included in v0.3.0 and later; not included in v0.2.0. See the [roadmap](ROADMAP.md).
Open a session in the admin console, Dashboard, or Companion's Sessions tab. The **Run explanation and recovery** section shows a snapshot of the recorded state and the next steps appropriate to it. Refresh the session to see changes.
diff --git a/docs/SITE_MAP.md b/docs/SITE_MAP.md
index e6db9a5a..196c1590 100644
--- a/docs/SITE_MAP.md
+++ b/docs/SITE_MAP.md
@@ -26,6 +26,8 @@ Use this map when turning the Markdown docs into a documentation website. It kee
| Guides | OpenSquilla Meta-Skill Migration | [opensquilla-meta-skill-migration.md](opensquilla-meta-skill-migration.md) |
| Guides | OpenSquilla Dynamic Turn Routing | [opensquilla-dynamic-turn-routing.md](opensquilla-dynamic-turn-routing.md) |
| Guides | Dynamic Routing and Model Profiles | [dynamic-turn-routing-model-profiles.md](dynamic-turn-routing-model-profiles.md) |
+| Guides | Nacos MCP Router PoC | [nacos-mcp-router.md](nacos-mcp-router.md) |
+| Guides | Nacos MCP Router PoC (zh-CN) | [zh-CN/nacos-mcp-router.md](zh-CN/nacos-mcp-router.md) |
| Guides | External CLI Connectors | [EXTERNAL_CLI_CONNECTORS.md](EXTERNAL_CLI_CONNECTORS.md) |
| Guides | Fractal Memory | [FRACTAL_MEMORY.md](FRACTAL_MEMORY.md) |
| Guides | Model Profiles | [MODEL_PROFILES.md](MODEL_PROFILES.md) |
diff --git a/docs/companion-token-storage.md b/docs/companion-token-storage.md
index e2cae4f1..dbffc250 100644
--- a/docs/companion-token-storage.md
+++ b/docs/companion-token-storage.md
@@ -1,6 +1,6 @@
# Companion token storage and legacy migration
-> **Release availability:** Available on `main` and targeted for v0.3.0; not included in v0.2.0. See the [roadmap](ROADMAP.md).
+> **Release availability:** Included in v0.3.0 and later; not included in v0.2.0. See the [roadmap](ROADMAP.md).
Companion stores remembered gateway tokens and provider API keys through the operating system:
diff --git a/docs/durable-actions.md b/docs/durable-actions.md
index 649e1498..f4a3959a 100644
--- a/docs/durable-actions.md
+++ b/docs/durable-actions.md
@@ -1,6 +1,6 @@
# Durable action reconciliation
-> **Release availability:** Available on `main` and targeted for v0.3.0; not included in v0.2.0. See the [roadmap](ROADMAP.md).
+> **Release availability:** Included in v0.3.0 and later; not included in v0.2.0. See the [roadmap](ROADMAP.md).
Set `OpenClaw:Tooling:DurableActionJournal` to `true` to journal native and MAF tool executor dispatches under `Memory.StoragePath/action-journal`. This is opt-in because it deliberately blocks work that older versions retried automatically.
diff --git a/docs/guided-recovery.md b/docs/guided-recovery.md
index 9853219a..b1520dca 100644
--- a/docs/guided-recovery.md
+++ b/docs/guided-recovery.md
@@ -1,6 +1,6 @@
# Guided operator recovery
-> **Release availability:** Available on `main` and targeted for v0.3.0; not included in v0.2.0. See the [roadmap](ROADMAP.md).
+> **Release availability:** Included in v0.3.0 and later; not included in v0.2.0. See the [roadmap](ROADMAP.md).
The admin session page now includes recovery controls beside session details. The explanation view in PR #213 complements these controls; it is not required to operate them.
diff --git a/docs/instance-backup.md b/docs/instance-backup.md
index 56623241..7cead789 100644
--- a/docs/instance-backup.md
+++ b/docs/instance-backup.md
@@ -1,6 +1,6 @@
# Full-instance offline backup and restore
-> **Release availability:** Available on `main` and targeted for v0.3.0; not included in v0.2.0. See the [roadmap](ROADMAP.md).
+> **Release availability:** Included in v0.3.0 and later; not included in v0.2.0. See the [roadmap](ROADMAP.md).
`openclaw backup` snapshots a declared inventory of instance directories, including file and SQLite state. It never starts a gateway, scheduler, plugin, model, or tool. Restoration always targets a new isolated directory and never replaces an existing deployment.
diff --git a/docs/nacos-mcp-router.md b/docs/nacos-mcp-router.md
new file mode 100644
index 00000000..62876aaf
--- /dev/null
+++ b/docs/nacos-mcp-router.md
@@ -0,0 +1,131 @@
+# Nacos MCP Router PoC
+
+Status: **mock-verified integration foundation; live acceptance pending** for
+[#229](https://github.com/clawdotnet/openclaw.net/issues/229). This guide does not
+claim that the Nacos deployment, discovery quality, or model-token baseline has
+been validated. The resolver/schema/cache work in #230–#234 depends on that evidence.
+
+## Contract observations
+
+The reference is the upstream Python Router at commit
+[`0ee95f4f353d6f66184dafdb3e0ffd342c4edb09`](https://github.com/nacos-group/nacos-mcp-router-python/blob/0ee95f4f353d6f66184dafdb3e0ffd342c4edb09/src/nacos_mcp_router/router.py).
+These are source observations, **not a live deployment capture**:
+
+| Tool | Arguments | Returned text |
+| --- | --- | --- |
+| `search_mcp_server` | `task_description`, `key_words` (comma-separated string) | Prose containing a JSON object keyed by server name; entries have `name` and `description`, no score |
+| `add_mcp_server` | `mcp_server_name` | Prose containing a tool list with `name`, `description`, `inputSchema` |
+| `use_tool` | `mcp_server_name`, `mcp_tool_name`, `params` | String representation of downstream MCP content |
+
+Do not substitute `tool_name` for `mcp_tool_name`, assume search is a bare JSON
+array, fabricate scores, or assume every failure sets MCP `isError`. The Python
+Router returns some initialization/install/unhealthy/use failures as ordinary
+text. Such responses cannot safely drive generic protocol-level fallback without
+a Router-specific normalization contract. The mock suite distinguishes these
+plain-text results from explicit MCP protocol failures.
+
+The server ID `nacos-mcp-router` retains its hyphens in default tool names.
+Configure `toolNamePrefix` explicitly to obtain the underscore names below.
+
+## Opt-in configuration
+
+Keep Nacos and Router on the same host when Nacos binds only to loopback. Do not
+change an existing deployment's networking or authentication for this example.
+With credentials provided by your existing secret environment, start the approved
+Router version using the upstream transport spelling:
+
+```sh
+: "${NACOS_ADDR:?Set the existing Nacos address}"
+: "${NACOS_USERNAME:?Set your Nacos username}"
+: "${NACOS_PASSWORD:?Supply through your secret environment}"
+: "${NACOS_ROUTER_VERSION:?Pin the Router version tested against your deployment}"
+export TRANSPORT_TYPE=streamable_http
+uvx "nacos-mcp-router@${NACOS_ROUTER_VERSION}"
+```
+
+Set `NACOS_ROUTER_VERSION` to an explicitly validated package version, not an
+unverified moving `latest`. Inspect startup output for the actual endpoint.
+Merge this entry into `/mcp/mcp.json`; preserve existing servers:
+
+```json
+{
+ "enabled": true,
+ "servers": {
+ "nacos-mcp-router": {
+ "enabled": true,
+ "transport": "http",
+ "url": "http://127.0.0.1:8000/mcp",
+ "toolNamePrefix": "nacos_mcp_router_"
+ }
+ }
+}
+```
+
+Register a test server named `weather-mcp` exposing `get_weather` with required
+string argument `city` using the deployment's supported Nacos console/API. Verify
+its registration and `add_mcp_server` output before running the static example.
+The referenced Windows compose deployment and architecture document are not in
+this repository, so this guide deliberately does not invent administrator-init
+commands or a version-specific registration payload.
+
+The examples stay under `examples/skills/` and are not bundled or enabled by
+default. Copy the two example directories into an isolated gateway workspace's
+`skills/` directory, or add their parent as an extra skills directory in that
+temporary configuration. Invoke `meta_invoke` with:
+
+```json
+{"skill":"nacos-router-weather","input":"Oslo"}
+```
+
+The static example binds on each invocation, then calls `use_tool`; it adds no
+LLM turn. Its final output is the raw tool result. The exploration example lets
+the model discover/bind/use and serves as a separate token baseline. Confirm the
+registered tool names rather than assuming the mock's weather schema exists.
+
+## Reproducible local checks
+
+From the repository root:
+
+```sh
+dotnet test src/OpenClaw.Tests -c Release --filter FullyQualifiedName~NacosRouterIntegrationTests
+```
+
+The tests start a real in-process HTTP MCP endpoint. They check exact three-tool
+registration, source-shaped discovery text, sequential bind/use execution in both
+runtimes, repeated execution without adding backend tools, and protocol-error
+fallback. They use no external credentials, model calls, Nacos server, or Docker.
+
+For a provisioned Router with a registered weather server:
+
+```sh
+export OPENCLAW_NACOS_LIVE=1
+export OPENCLAW_NACOS_ROUTER_URL=http://127.0.0.1:8000/mcp
+dotnet test src/OpenClaw.Tests -c Release --filter FullyQualifiedName~LiveRouter
+```
+
+Unset `OPENCLAW_NACOS_LIVE` for normal CI; the live check is skipped.
+
+## Remaining live acceptance and downstream decisions
+
+Before closing #229 or proceeding with the dependent runtime changes:
+
+1. Capture the real three tool schemas, success/error responses, and Router
+ package version from the intended Nacos 3.2.4 deployment. Redact credentials.
+2. Record the actual weather-server registration payload and reproducible startup
+ commands from that deployment; the issue's Windows path is not portable.
+3. Run both examples five times with the same city, model, fresh session, and
+ configuration. Read actual session input/output usage; take the median of the
+ five per-run totals. Record discovery quality separately. Do not use invented
+ fixture token counts as a model measurement.
+4. Decide how resolver results get ranking/version metadata (upstream search
+ provides neither score nor version), and how prose failures become typed
+ failures before implementing fallback, retries, caching, or replay.
+
+| Measurement | Static binding | Model-driven exploration |
+| --- | --- | --- |
+| Live recall / selected server | Not measured | Not measured |
+| Median input + output tokens (5 runs) | Not measured | Not measured |
+| Router version / deployment | Not provided | Not provided |
+
+No cache, capability slots, retry policy, or binding replay is implemented by this
+PoC. Those remain separately tracked by #230–#234.
diff --git a/docs/testing/trajectory-replay.md b/docs/testing/trajectory-replay.md
index 30a786ce..fd484da3 100644
--- a/docs/testing/trajectory-replay.md
+++ b/docs/testing/trajectory-replay.md
@@ -1,6 +1,6 @@
# Turn a captured exchange into an offline regression
-> **Release availability:** Available on `main` and targeted for v0.3.0; not included in v0.2.0. See the [roadmap](../ROADMAP.md).
+> **Release availability:** Included in v0.3.0 and later; not included in v0.2.0. See the [roadmap](../ROADMAP.md).
`OpenClaw.Testing` can import one user exchange from the gateway's version 1 JSONL trajectory export and replay its provider responses and tool outputs through the real runtime. This tests orchestration, tool dispatch, approval behavior, and output assertions without repeating external actions.
diff --git a/docs/zh-CN/nacos-mcp-router.md b/docs/zh-CN/nacos-mcp-router.md
new file mode 100644
index 00000000..43c28ca5
--- /dev/null
+++ b/docs/zh-CN/nacos-mcp-router.md
@@ -0,0 +1,45 @@
+# Nacos MCP Router 概念验证
+
+状态:**已提供可复现 mock 集成基础;真实部署验收尚未完成**。对应
+[#229](https://github.com/clawdotnet/openclaw.net/issues/229),完整配置和命令见
+[英文指南](../nacos-mcp-router.md)。没有宣称已验证真实召回率或模型 token 基线。
+
+## 已确认的契约差异
+
+依据上游 Python Router 固定提交 `0ee95f4f353d6f66184dafdb3e0ffd342c4edb09` 的源码:
+
+- search 参数是 `task_description` 和逗号分隔字符串 `key_words`。
+- use 参数是 `mcp_server_name`、`mcp_tool_name`、`params`,不是 `tool_name`。
+- search 返回嵌有 JSON 对象的说明文字,没有 score/version 字段。
+- 部分安装、健康检查、执行错误只是普通文本,不设置 MCP `isError`。
+- server ID 中的连字符会保留。显式设置 `toolNamePrefix: nacos_mcp_router_`
+ 才能保证示例中的三个下划线工具名。
+
+这些是源码观察,不是假称的真实端点抓包。协议级失败与普通错误文本必须区分;
+后续 Resolver 需要定义可靠的解析、排序、版本约束及错误归一化契约。
+
+## 验证范围
+
+`examples/skills/nacos-router-weather` 使用 bind → query 的静态 DAG,并直接返回
+工具结果。`nacos-router-weather-explore` 提供模型驱动的 search → add → use 基线。
+示例不进入生产技能索引,需在独立测试 workspace 手动启用。
+
+运行 mock 测试:
+
+```sh
+dotnet test src/OpenClaw.Tests -c Release --filter FullyQualifiedName~NacosRouterIntegrationTests
+```
+
+已有真实 Router 和 weather-mcp 注册后,可设置 `OPENCLAW_NACOS_LIVE=1`、
+`OPENCLAW_NACOS_ROUTER_URL`,运行 `FullyQualifiedName~LiveRouter` 筛选器。
+未设置时跳过 live 测试,普通 CI 不依赖外部服务。
+
+## 尚未具备的验收材料
+
+Issue 引用的 Windows compose 部署与架构文档不在仓库中。必须补充真实 Router
+版本、三个工具的参数及成功/错误响应、注册 payload 和可复制的启动命令。
+使用同一模型、输入与配置,对两个示例各执行五次,记录真实 session usage 的
+input+output 总和中位数,并单独记录召回质量。目前这两组数据均未测量。
+
+因此 #229 仍未全部完成,#230–#234 的依赖验收仍待完成;不能把 mock 数据当成
+真实 token 基线,也不能凭空补全搜索分数、版本信息或错误语义。
diff --git a/eng/test_verify_desktop_first_success.py b/eng/test_verify_desktop_first_success.py
new file mode 100644
index 00000000..7e3a6ee9
--- /dev/null
+++ b/eng/test_verify_desktop_first_success.py
@@ -0,0 +1,33 @@
+"""The packaged gate must reject error responses masquerading as a tool round trip."""
+import importlib.util
+from pathlib import Path
+import threading
+import unittest
+from http.server import ThreadingHTTPServer
+
+spec = importlib.util.spec_from_file_location("desktop_smoke", Path(__file__).with_name("verify-desktop-first-success.py"))
+smoke = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(smoke)
+
+
+class ToolResultContractTests(unittest.TestCase):
+ def test_only_known_memory_result_completes_turn(self):
+ server = ThreadingHTTPServer(("127.0.0.1", 0), smoke.OllamaFixtureHandler)
+ worker = threading.Thread(target=server.serve_forever, daemon=True)
+ worker.start()
+ try:
+ for content in ("Error: tool denied", "Note 'desktop-release-contract' not found.", smoke.NOTE_TEXT):
+ with self.subTest(content=content):
+ response = smoke.read_json(
+ f"http://127.0.0.1:{server.server_port}/api/chat",
+ payload={"messages": [{"role": "tool", "content": content}]},
+ )
+ self.assertEqual(response["message"]["content"] == smoke.FINAL_TEXT, content == smoke.NOTE_TEXT)
+ finally:
+ server.shutdown()
+ server.server_close()
+ worker.join(timeout=5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/eng/verify-desktop-first-success.py b/eng/verify-desktop-first-success.py
new file mode 100644
index 00000000..c3a7985d
--- /dev/null
+++ b/eng/verify-desktop-first-success.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""Exercise packaged CLI/setup and gateway through an Ollama-compatible tool round trip."""
+
+import argparse
+import base64
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import socket
+import subprocess
+import tempfile
+import threading
+import time
+from urllib.error import HTTPError, URLError
+from urllib.request import Request, urlopen
+
+
+FINAL_TEXT = "desktop packaged first success complete"
+NOTE_KEY = "desktop-release-contract"
+NOTE_TEXT = "verified desktop release memory sentinel"
+
+
+def free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
+ listener.bind(("127.0.0.1", 0))
+ return listener.getsockname()[1]
+
+
+class OllamaFixtureHandler(BaseHTTPRequestHandler):
+ requests: list[dict] = []
+
+ def do_GET(self) -> None:
+ if self.path == "/health":
+ self.send_json({"status": "ok"})
+ else:
+ self.send_error(404)
+
+ def do_POST(self) -> None:
+ if self.path != "/api/chat":
+ self.send_error(404)
+ return
+ length = int(self.headers.get("Content-Length", "0"))
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
+ self.requests.append(payload)
+ tool_results = [message for message in payload.get("messages", []) if message.get("role") == "tool"]
+ if tool_results:
+ valid = any(message.get("content") == NOTE_TEXT for message in tool_results)
+ message = {"role": "assistant", "content": FINAL_TEXT if valid else "unexpected tool result"}
+ else:
+ message = {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {"function": {"name": "memory_get", "arguments": {"key": NOTE_KEY}}}
+ ],
+ }
+ self.send_json({"message": message, "done": True, "done_reason": "stop", "prompt_eval_count": 12, "eval_count": 4})
+
+ def send_json(self, payload: dict) -> None:
+ body = json.dumps(payload).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, format: str, *args: object) -> None:
+ del format, args
+
+
+def read_json(
+ url: str,
+ token: str | None = None,
+ payload: dict | None = None,
+ timeout: float = 5,
+) -> dict:
+ data = None if payload is None else json.dumps(payload).encode("utf-8")
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ request = Request(url, data=data, headers=headers, method="POST" if payload is not None else "GET")
+ with urlopen(request, timeout=timeout) as response:
+ return json.loads(response.read().decode("utf-8"))
+
+
+def read_gateway_log(path: Path) -> str:
+ return path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
+
+
+def wait_for_health(url: str, process: subprocess.Popen[str], log_path: Path) -> None:
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ raise RuntimeError(
+ f"Packaged gateway exited before readiness.\n{read_gateway_log(log_path)}"
+ )
+ try:
+ with urlopen(url, timeout=5) as response:
+ response.read()
+ return
+ except (HTTPError, URLError, TimeoutError):
+ time.sleep(0.25)
+ raise TimeoutError("Packaged gateway did not become healthy within 30 seconds.")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--cli", required=True)
+ parser.add_argument("--gateway", required=True)
+ args = parser.parse_args()
+
+ cli = str(Path(args.cli).resolve(strict=True))
+ gateway = str(Path(args.gateway).resolve(strict=True))
+ OllamaFixtureHandler.requests = []
+ provider = ThreadingHTTPServer(("127.0.0.1", 0), OllamaFixtureHandler)
+ provider_port = provider.server_port
+ gateway_port = free_port()
+ provider_thread = threading.Thread(target=provider.serve_forever, daemon=True)
+ provider_thread.start()
+
+ try:
+ with tempfile.TemporaryDirectory(prefix="openclaw-desktop-first-success-") as temporary:
+ root = Path(temporary)
+ config_path = root / "config" / "openclaw.settings.json"
+ workspace = root / "workspace"
+ setup = subprocess.run(
+ [
+ cli,
+ "setup",
+ "--non-interactive",
+ "--profile", "local",
+ "--workspace", str(workspace),
+ "--provider", "ollama",
+ "--model", "fixture-model",
+ "--model-preset", "ollama-agentic",
+ "--config", str(config_path),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ if setup.returncode:
+ raise RuntimeError(f"Packaged CLI setup failed.\n{setup.stdout}\n{setup.stderr}")
+
+ config = json.loads(config_path.read_text(encoding="utf-8-sig"))
+ openclaw = config["OpenClaw"]
+ openclaw["port"] = gateway_port
+ openclaw["llm"]["endpoint"] = f"http://127.0.0.1:{provider_port}"
+ profile = openclaw["models"]["profiles"][0]
+ profile["baseUrl"] = f"http://127.0.0.1:{provider_port}"
+ if profile["presetId"] != "ollama-agentic":
+ raise AssertionError("Packaged CLI did not persist the ollama-agentic preset.")
+ capabilities = profile["capabilities"]
+ if capabilities["supportsTools"] is not True or capabilities["supportsParallelToolCalls"] is not False:
+ raise AssertionError("Packaged CLI did not persist sequential tool capabilities.")
+ config_path.write_text(json.dumps(config), encoding="utf-8")
+ # Seed the file-memory store so errors or missing notes cannot satisfy the gate.
+ notes = Path(openclaw["memory"]["storagePath"]) / "notes"
+ notes.mkdir(parents=True, exist_ok=True)
+ encoded_key = base64.urlsafe_b64encode(NOTE_KEY.encode()).decode().rstrip("=")
+ (notes / f"{encoded_key}.md").write_text(NOTE_TEXT, encoding="utf-8")
+
+ gateway_log_path = root / "gateway.log"
+ with gateway_log_path.open("w", encoding="utf-8") as gateway_log:
+ process = subprocess.Popen(
+ [gateway, "--config", str(config_path)],
+ stdout=gateway_log,
+ stderr=subprocess.STDOUT,
+ text=True,
+ cwd=root,
+ )
+ try:
+ base_url = f"http://127.0.0.1:{gateway_port}"
+ wait_for_health(f"{base_url}/health", process, gateway_log_path)
+ response = read_json(
+ f"{base_url}/v1/chat/completions",
+ openclaw.get("authToken"),
+ {
+ "model": "local-primary",
+ "messages": [{"role": "user", "content": "Read the desktop release contract memory note."}],
+ },
+ timeout=60,
+ )
+ rendered = json.dumps(response)
+ if FINAL_TEXT not in rendered:
+ raise AssertionError(f"Gateway response did not contain the fixture completion: {rendered}")
+ except Exception as error:
+ gateway_log.flush()
+ raise RuntimeError(
+ f"{error}\nPackaged gateway log:\n{read_gateway_log(gateway_log_path)}"
+ ) from error
+ finally:
+ process.terminate()
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=5)
+
+ if len(OllamaFixtureHandler.requests) != 2:
+ raise AssertionError(f"Expected two sequential provider requests, got {len(OllamaFixtureHandler.requests)}.")
+ first, second = OllamaFixtureHandler.requests
+ if any(request.get("model") != "fixture-model" for request in (first, second)):
+ raise AssertionError("Gateway did not use the configured model.")
+ if len(first.get("tools", [])) < 2:
+ raise AssertionError("Gateway did not advertise multiple tools to the sequential-tool model.")
+ if not any(message.get("role") == "tool" and message.get("content") == NOTE_TEXT for message in second.get("messages", [])):
+ raise AssertionError("Gateway did not return the tool result before requesting the final response.")
+ finally:
+ provider.shutdown()
+ provider.server_close()
+ provider_thread.join(timeout=5)
+
+ print("Packaged Ollama Agentic setup, gateway start, and sequential tool round trip passed.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eng/verify-release-docs.py b/eng/verify-release-docs.py
new file mode 100644
index 00000000..981d8bae
--- /dev/null
+++ b/eng/verify-release-docs.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""Reject release documentation that still describes the release tag as future work."""
+
+from pathlib import Path
+import re
+import sys
+
+
+def normalize_tag(value: str) -> tuple[str, str]:
+ tag = value.strip()
+ match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)", tag)
+ if match is None:
+ raise ValueError(f"Expected a semantic release tag such as v0.3.0, got {value!r}.")
+ version = match.group(1)
+ return f"v{version}", version
+
+
+def markdown_files(root: Path) -> list[Path]:
+ candidates = [root / "README.md", root / "CHANGELOG.md"]
+ candidates.extend((root / "docs").rglob("*.md"))
+ return sorted(path for path in candidates if path.is_file())
+
+
+def find_stale_references(root: Path, tag: str, version: str) -> list[str]:
+ escaped_tag = re.escape(tag)
+ escaped_version = re.escape(version)
+ patterns = [
+ re.compile(rf"\btarget(?:ed)?(?:\s+for)?\s+\*{{0,2}}{escaped_tag}\*{{0,2}}", re.IGNORECASE),
+ re.compile(rf"\bmain[ -]only\b[^\n]{{0,120}}\b{escaped_tag}\b", re.IGNORECASE),
+ re.compile(rf"\bnot (?:available|included) in\s+\*{{0,2}}{escaped_tag}\*{{0,2}}", re.IGNORECASE),
+ re.compile(rf"\bplanned for\s+\*{{0,2}}v?{escaped_version}\*{{0,2}}", re.IGNORECASE),
+ ]
+ failures: list[str] = []
+ for path in markdown_files(root):
+ for line_number, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), start=1):
+ if any(pattern.search(line) for pattern in patterns):
+ failures.append(f"{path.relative_to(root)}:{line_number}: {line.strip()}")
+ return failures
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print("usage: verify-release-docs.py ", file=sys.stderr)
+ return 2
+
+ try:
+ tag, version = normalize_tag(sys.argv[1])
+ except ValueError as error:
+ print(error, file=sys.stderr)
+ return 2
+
+ root = Path(__file__).resolve().parent.parent
+ failures = find_stale_references(root, tag, version)
+ if failures:
+ print(f"Release documentation still describes {tag} as future or unavailable:", file=sys.stderr)
+ for failure in failures:
+ print(f"- {failure}", file=sys.stderr)
+ print("Update roadmap and availability banners before publishing the tag.", file=sys.stderr)
+ return 1
+
+ print(f"Release documentation contains no stale future-work markers for {tag}.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/skills/nacos-router-weather-explore/SKILL.md b/examples/skills/nacos-router-weather-explore/SKILL.md
new file mode 100644
index 00000000..57acc938
--- /dev/null
+++ b/examples/skills/nacos-router-weather-explore/SKILL.md
@@ -0,0 +1,15 @@
+---
+name: nacos-router-weather-explore
+description: "Opt-in model-driven Nacos Router weather baseline."
+kind: standard
+always: false
+triggers: ["explore nacos weather"]
+---
+For the input city, call `nacos_mcp_router_search_mcp_server` with
+`task_description` and `key_words` (a comma-separated string). Select an appropriate
+server from the actual returned text. Call `nacos_mcp_router_add_mcp_server` with
+`mcp_server_name`, inspect its actual tool list, then call
+`nacos_mcp_router_use_tool` with `mcp_server_name`, `mcp_tool_name`, and `params`.
+Treat Router initialization, missing-server, unhealthy-server, and failed-install
+messages as failures even when the MCP response is not marked as an error.
+Report a failure rather than inventing weather. Summarize successful weather data.
diff --git a/examples/skills/nacos-router-weather/SKILL.md b/examples/skills/nacos-router-weather/SKILL.md
new file mode 100644
index 00000000..df297d64
--- /dev/null
+++ b/examples/skills/nacos-router-weather/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: nacos-router-weather
+description: "Opt-in Nacos Router weather PoC; requires a registered weather-mcp server."
+kind: meta
+always: false
+final_text_mode: "step:query"
+triggers: ["nacos weather"]
+composition:
+ steps:
+ - id: bind
+ kind: tool_call
+ tool: nacos_mcp_router_add_mcp_server
+ tool_args:
+ mcp_server_name: weather-mcp
+ - id: query
+ kind: tool_call
+ tool: nacos_mcp_router_use_tool
+ depends_on: [bind]
+ tool_args:
+ mcp_server_name: weather-mcp
+ mcp_tool_name: get_weather
+ params:
+ city: "{{ input }}"
+ on_failure: fallback_notice
+ - id: fallback_notice
+ kind: tool_call
+ tool: emit_text
+ tool_args:
+ text: "weather-mcp unavailable; check Nacos registration and Router logs."
+---
+This is a static, opt-in contract fixture. Register `weather-mcp` with a
+`get_weather(city)` tool before running it. The final output is the tool result;
+no model call is needed for this deterministic path. Router prose errors are
+not MCP protocol failures: see docs/nacos-mcp-router.md before live use.
diff --git a/src/OpenClaw.Agent/Tools/McpNativeTool.cs b/src/OpenClaw.Agent/Tools/McpNativeTool.cs
index f435ec07..1c67fb29 100644
--- a/src/OpenClaw.Agent/Tools/McpNativeTool.cs
+++ b/src/OpenClaw.Agent/Tools/McpNativeTool.cs
@@ -65,8 +65,14 @@ private async ValueTask ExecuteCoreAsync(string argumentsJson, ToolExecu
var text = FormatResponseContent(response, suppressStructuredContent);
var isError = response.IsError ?? false;
+ if (isError && context is not null)
+ throw new ToolOutcomeException($"Error: {text}", "failed", "mcp_tool_error", text);
return isError ? $"Error: {text}" : text;
}
+ catch (ToolOutcomeException)
+ {
+ throw;
+ }
catch (JsonException ex)
{
return $"Error: Invalid JSON arguments for MCP tool '{localName}': {ex.Message}";
diff --git a/src/OpenClaw.Companion/App.axaml b/src/OpenClaw.Companion/App.axaml
index 524d72b3..d6de2d1f 100644
--- a/src/OpenClaw.Companion/App.axaml
+++ b/src/OpenClaw.Companion/App.axaml
@@ -3,7 +3,7 @@
x:Class="OpenClaw.Companion.App"
xmlns:local="using:OpenClaw.Companion"
xmlns:conv="using:OpenClaw.Companion.Converters"
- Name="AgentQi [OpenClaw.NET]"
+ Name="AgentQi Companion"
RequestedThemeVariant="Default">
diff --git a/src/OpenClaw.Companion/Assets/BRANDING.md b/src/OpenClaw.Companion/Assets/BRANDING.md
index 5b69ba3e..7ee40dc7 100644
--- a/src/OpenClaw.Companion/Assets/BRANDING.md
+++ b/src/OpenClaw.Companion/Assets/BRANDING.md
@@ -1,6 +1,10 @@
# AgentQi Companion branding
-Display name: **AgentQi [OpenClaw.NET]**. Assembly names, protocol identifiers, settings paths, and credential-store identifiers remain compatible with existing installations.
+Canonical desktop product name: **AgentQi Companion**.
+
+Relationship descriptor: **AgentQi Companion for OpenClaw.NET**. Use this in explanatory copy where the runtime relationship needs to be explicit; do not treat the descriptor as an executable, package, protocol, or storage identity.
+
+Assembly names, protocol identifiers, settings paths, and credential-store identifiers remain compatible with existing installations.
- `agentqi-wordmark.png`: supplied light wordmark, copied unchanged.
- `agentqi-artwork.png`: supplied dark artwork, copied unchanged.
diff --git a/src/OpenClaw.Companion/OpenClaw.Companion.csproj b/src/OpenClaw.Companion/OpenClaw.Companion.csproj
index 18420958..a2758b97 100644
--- a/src/OpenClaw.Companion/OpenClaw.Companion.csproj
+++ b/src/OpenClaw.Companion/OpenClaw.Companion.csproj
@@ -1,8 +1,8 @@
WinExe
- AgentQi [OpenClaw.NET]
- AgentQi [OpenClaw.NET]
+ AgentQi Companion
+ AgentQi Companion
net10.0
enable
app.manifest
diff --git a/src/OpenClaw.Companion/Services/DesktopNotifier.cs b/src/OpenClaw.Companion/Services/DesktopNotifier.cs
index 60a7065d..3b6a4b93 100644
--- a/src/OpenClaw.Companion/Services/DesktopNotifier.cs
+++ b/src/OpenClaw.Companion/Services/DesktopNotifier.cs
@@ -109,7 +109,7 @@ private static Task NotifyMacOsAsync(string title, string body, CancellationToke
}
private static Task NotifyLinuxAsync(string title, string body, CancellationToken ct)
- => RunProcessAsync("notify-send", ["--app-name=AgentQi [OpenClaw.NET]", "--", title, body], ct);
+ => RunProcessAsync("notify-send", ["--app-name=AgentQi Companion", "--", title, body], ct);
private static async Task RunProcessAsync(string fileName, string[] arguments, CancellationToken ct)
{
diff --git a/src/OpenClaw.Companion/Views/MainWindow.axaml b/src/OpenClaw.Companion/Views/MainWindow.axaml
index 3ec738f7..7fe65ecd 100644
--- a/src/OpenClaw.Companion/Views/MainWindow.axaml
+++ b/src/OpenClaw.Companion/Views/MainWindow.axaml
@@ -9,7 +9,7 @@
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/agentqi-icon.png"
Width="1440" Height="960" MinWidth="1000" MinHeight="700" WindowStartupLocation="CenterScreen" WindowState="Maximized"
- Title="AgentQi [OpenClaw.NET]" Classes="companion-window">
+ Title="AgentQi Companion" Classes="companion-window">
@@ -19,8 +19,8 @@
-
-
+
+
@@ -269,18 +269,18 @@
-
+
-
+
-
+
@@ -300,7 +300,7 @@
-
+
diff --git a/src/OpenClaw.Tests/CompanionCanvasUiTests.cs b/src/OpenClaw.Tests/CompanionCanvasUiTests.cs
index 575e590f..389c105b 100644
--- a/src/OpenClaw.Tests/CompanionCanvasUiTests.cs
+++ b/src/OpenClaw.Tests/CompanionCanvasUiTests.cs
@@ -103,7 +103,7 @@ public void MainWindow_RendersRuntimeConsoleShellAndNavigationSections()
var tabControl = window.GetVisualDescendants().OfType().Single();
Assert.Equal(Dock.Left, tabControl.TabStripPlacement);
- Assert.Contains(window.GetVisualDescendants().OfType(), text => string.Equals(text.Text, "AgentQi", StringComparison.Ordinal));
+ Assert.Contains(window.GetVisualDescendants().OfType(), text => string.Equals(text.Text, "AgentQi Companion", StringComparison.Ordinal));
var headers = tabControl.Items.OfType().Select(static item => item.Header?.ToString()).ToArray();
Assert.Contains("Home", headers);
diff --git a/src/OpenClaw.Tests/DesktopFirstSuccessContractTests.cs b/src/OpenClaw.Tests/DesktopFirstSuccessContractTests.cs
new file mode 100644
index 00000000..146717e0
--- /dev/null
+++ b/src/OpenClaw.Tests/DesktopFirstSuccessContractTests.cs
@@ -0,0 +1,164 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Avalonia.Headless.XUnit;
+using Avalonia.Threading;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using OpenClaw.Agent;
+using OpenClaw.Companion.Services;
+using OpenClaw.Companion.ViewModels;
+using OpenClaw.Companion.Views;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Setup;
+using OpenClaw.Gateway.Extensions;
+using OpenClaw.Gateway.Models;
+using Xunit;
+
+namespace OpenClaw.Tests;
+
+public sealed class DesktopFirstSuccessContractTests
+{
+ [AvaloniaFact]
+ [Trait("Category", "DesktopRelease")]
+ public async Task OllamaAgentic_FromCompanionBinding_SelectsSequentialModelAndCompletesToolCall()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "desktop-first-success", Guid.NewGuid().ToString("N"));
+ var store = new SettingsStore(directory);
+ var viewModel = new MainWindowViewModel(store, new GatewayWebSocketClient());
+ var window = new MainWindow { DataContext = viewModel };
+ try
+ {
+ window.Show();
+ viewModel.SetupProvider = "ollama";
+ viewModel.SetupModel = "llama3.2";
+ viewModel.SetupModelPreset = "ollama-agentic";
+ Dispatcher.UIThread.RunJobs();
+
+ var settings = store.Load();
+ Assert.Equal("llama3.2", settings.SetupModel);
+ Assert.Equal("ollama-agentic", settings.SetupModelPreset);
+ Assert.All(viewModel.SetupModelPresetOptions, id =>
+ Assert.True(LocalModelPresetCatalog.TryGet(id, out _), $"Setup offers unknown preset '{id}'."));
+
+ var config = GatewaySetupProfileFactory.CreateProfileConfig(
+ "local",
+ "127.0.0.1",
+ 18789,
+ "test-token",
+ directory,
+ Path.Combine(directory, "memory"),
+ settings.SetupProvider!,
+ settings.SetupModel,
+ "",
+ settings.SetupModelPreset);
+ var profile = Assert.Single(config.Models.Profiles);
+ Assert.Equal("ollama-agentic", profile.PresetId);
+ Assert.NotNull(profile.Capabilities);
+ var capabilities = profile.Capabilities!;
+ Assert.True(capabilities.SupportsTools);
+ Assert.False(capabilities.SupportsParallelToolCalls);
+
+ using var registry = new ConfiguredModelProfileRegistry(config, NullLogger.Instance);
+ registry.SetDefaultProfileId();
+ var selectionPolicy = new DefaultModelSelectionPolicy(registry);
+ using var schema = JsonDocument.Parse("""{"type":"object","properties":{"value":{"type":"string"}}}""");
+ var selection = selectionPolicy.Resolve(new ModelSelectionRequest
+ {
+ Session = new Session
+ {
+ Id = "desktop-first-success",
+ ChannelId = "desktop",
+ SenderId = "operator",
+ ModelProfileId = "local-primary"
+ },
+ Messages = [new ChatMessage(ChatRole.User, "Record the first successful tool call.")],
+ Options = new ChatOptions
+ {
+ Tools =
+ [
+ AIFunctionFactory.CreateDeclaration("record_observation", "Record an observation", schema.RootElement.Clone(), returnJsonSchema: null),
+ AIFunctionFactory.CreateDeclaration("read_observation", "Read an observation", schema.RootElement.Clone(), returnJsonSchema: null)
+ ]
+ }
+ });
+
+ Assert.Equal("local-primary", selection.SelectedProfileId);
+ Assert.True(selection.Requirements.SupportsTools);
+ Assert.Null(selection.Requirements.SupportsParallelToolCalls);
+
+ var handler = new SequentialOllamaHandler();
+ using var httpClient = new HttpClient(handler);
+ using var ollama = new OllamaChatClient(config.Llm, httpClient);
+ var tool = new RecordingTool();
+ var runtime = new AgentRuntime(
+ ollama,
+ [tool, new NoOpTool()],
+ Substitute.For(),
+ config.Llm,
+ maxHistoryTurns: 10);
+ var result = await runtime.RunAsync(
+ new Session { Id = "tool-round-trip", ChannelId = "desktop", SenderId = "operator" },
+ "Record the first successful tool call.",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal("desktop first success complete", result);
+ Assert.Equal(1, tool.CallCount);
+ Assert.Equal(2, handler.RequestBodies.Count);
+ using var firstRequest = JsonDocument.Parse(handler.RequestBodies[0]);
+ Assert.Equal("llama3.2", firstRequest.RootElement.GetProperty("model").GetString());
+ Assert.Contains("\"record_observation\"", handler.RequestBodies[0], StringComparison.Ordinal);
+ Assert.Contains("\"read_observation\"", handler.RequestBodies[0], StringComparison.Ordinal);
+ Assert.Contains("\"role\":\"tool\"", handler.RequestBodies[1], StringComparison.Ordinal);
+ }
+ finally
+ {
+ window.Close();
+ if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ private sealed class SequentialOllamaHandler : HttpMessageHandler
+ {
+ public List RequestBodies { get; } = [];
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Assert.Equal("/api/chat", request.RequestUri!.AbsolutePath);
+ RequestBodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
+ var body = RequestBodies.Count == 1
+ ? """{"message":{"content":"","tool_calls":[{"function":{"name":"record_observation","arguments":{"value":"ready"}}}]},"done_reason":"stop","prompt_eval_count":12,"eval_count":4}"""
+ : """{"message":{"content":"desktop first success complete"},"done_reason":"stop","prompt_eval_count":18,"eval_count":5}""";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json")
+ };
+ }
+ }
+
+ private sealed class RecordingTool : ITool
+ {
+ private int _callCount;
+
+ public int CallCount => Volatile.Read(ref _callCount);
+ public string Name => "record_observation";
+ public string Description => "Record an observation.";
+ public string ParameterSchema => """{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ {
+ Interlocked.Increment(ref _callCount);
+ return ValueTask.FromResult("recorded");
+ }
+ }
+
+ private sealed class NoOpTool : ITool
+ {
+ public string Name => "read_observation";
+ public string Description => "Read an observation.";
+ public string ParameterSchema => """{"type":"object","properties":{"value":{"type":"string"}}}""";
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) => ValueTask.FromResult("none");
+ }
+}
diff --git a/src/OpenClaw.Tests/DocsConsistencyTests.cs b/src/OpenClaw.Tests/DocsConsistencyTests.cs
index 306fb19a..1d245e9b 100644
--- a/src/OpenClaw.Tests/DocsConsistencyTests.cs
+++ b/src/OpenClaw.Tests/DocsConsistencyTests.cs
@@ -93,6 +93,38 @@ public void CompatibilityGuide_ContainsCanonicalSections()
Assert.Contains("## Known Limitations", compatibility, StringComparison.Ordinal);
}
+ [Fact]
+ public void BrandingSurfaces_DefineAgentQiCompanionWithoutRenamingRuntimeIdentity()
+ {
+ var root = FindRepositoryRoot();
+ var paths = new[]
+ {
+ "README.md",
+ "docs/ARCHITECTURE_BOUNDARIES.md",
+ "src/OpenClaw.Companion/Assets/BRANDING.md",
+ "src/OpenClaw.Companion/OpenClaw.Companion.csproj",
+ "src/OpenClaw.Companion/App.axaml",
+ "src/OpenClaw.Companion/Views/MainWindow.axaml",
+ "src/OpenClaw.Companion/Services/DesktopNotifier.cs"
+ };
+ var surfaces = paths.ToDictionary(
+ path => path,
+ path => File.ReadAllText(Path.Combine(root, path)),
+ StringComparer.Ordinal);
+
+ Assert.All(surfaces, surface =>
+ {
+ Assert.Contains("AgentQi Companion", surface.Value, StringComparison.Ordinal);
+ Assert.DoesNotContain("AgentQi [OpenClaw.NET]", surface.Value, StringComparison.Ordinal);
+ });
+ Assert.Contains("OpenClaw.NET** | Repository and runtime identity", surfaces["docs/ARCHITECTURE_BOUNDARIES.md"], StringComparison.Ordinal);
+ Assert.Contains("AgentQi** | Documentation and ecosystem umbrella", surfaces["docs/ARCHITECTURE_BOUNDARIES.md"], StringComparison.Ordinal);
+ Assert.Contains("AgentQiX** | Reserved likely future runtime identity", surfaces["docs/ARCHITECTURE_BOUNDARIES.md"], StringComparison.Ordinal);
+ Assert.Contains("GitHub release titles | **OpenClaw.NET**", surfaces["docs/ARCHITECTURE_BOUNDARIES.md"], StringComparison.Ordinal);
+ Assert.Contains("Desktop window chrome", surfaces["docs/ARCHITECTURE_BOUNDARIES.md"], StringComparison.Ordinal);
+ Assert.Contains("AgentQi Companion", surfaces["src/OpenClaw.Companion/OpenClaw.Companion.csproj"], StringComparison.Ordinal);
+ }
+
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
diff --git a/src/OpenClaw.Tests/FakeNacosRouterMcpTools.cs b/src/OpenClaw.Tests/FakeNacosRouterMcpTools.cs
new file mode 100644
index 00000000..d6b5b92e
--- /dev/null
+++ b/src/OpenClaw.Tests/FakeNacosRouterMcpTools.cs
@@ -0,0 +1,48 @@
+using System.ComponentModel;
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace OpenClaw.Tests;
+
+public sealed class NacosRouterFixtureState
+{
+ public List Calls { get; } = [];
+ public bool FailUse { get; set; }
+ public bool PlainTextFailure { get; set; }
+}
+
+// Parameters and prose envelopes follow the pinned upstream Python Router.
+// Scores are deliberately absent: upstream search does not supply them.
+[McpServerToolType]
+public sealed class FakeNacosRouterMcpTools(NacosRouterFixtureState state)
+{
+ [McpServerTool(Name = "search_mcp_server", ReadOnly = true), Description("Search by task and comma-separated keywords.")]
+ public string Search(string task_description, string key_words)
+ {
+ state.Calls.Add("search");
+ var candidates = Enumerable.Range(0, 5).ToDictionary(
+ i => i == 0 ? "weather-mcp" : $"candidate-{i}",
+ i => new { name = i == 0 ? "weather-mcp" : $"candidate-{i}", description = "weather city" });
+ return "### 1. 当前可用的mcp server列表为:" + JsonSerializer.Serialize(candidates)
+ + "\n### 2. 从当前可用的mcp server列表中选择你需要的mcp server调add_mcp_server工具安装mcp server";
+ }
+
+ [McpServerTool(Name = "add_mcp_server"), Description("Bind a registered server.")]
+ public string Add(string mcp_server_name)
+ {
+ state.Calls.Add("add:" + mcp_server_name);
+ return "1. " + mcp_server_name + "安装完成, tool 列表为: "
+ + "[{\"name\":\"get_weather\",\"description\":\"weather city\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}]";
+ }
+
+ [McpServerTool(Name = "use_tool"), Description("Proxy an installed tool.")]
+ public CallToolResult Use(string mcp_server_name, string mcp_tool_name, Dictionary @params)
+ {
+ state.Calls.Add("use:" + mcp_server_name + ":" + mcp_tool_name);
+ var invalid = mcp_server_name != "weather-mcp" || mcp_tool_name != "get_weather";
+ var failed = state.FailUse || invalid;
+ var text = failed ? "failed to use tool: " + mcp_tool_name : "Weather for " + @params["city"].GetString() + ": sunny";
+ return new CallToolResult { IsError = failed && !state.PlainTextFailure, Content = [new TextContentBlock { Text = text }] };
+ }
+}
diff --git a/src/OpenClaw.Tests/NacosRouterIntegrationTests.cs b/src/OpenClaw.Tests/NacosRouterIntegrationTests.cs
new file mode 100644
index 00000000..ea899f0c
--- /dev/null
+++ b/src/OpenClaw.Tests/NacosRouterIntegrationTests.cs
@@ -0,0 +1,151 @@
+using System.Reflection;
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Server;
+using NSubstitute;
+using OpenClaw.Agent;
+using OpenClaw.Agent.Plugins;
+using OpenClaw.Agent.Tools;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Memory;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Observability;
+using OpenClaw.Core.Plugins;
+using OpenClaw.Core.Skills;
+using OpenClaw.MicrosoftAgentFrameworkAdapter;
+using Xunit;
+
+namespace OpenClaw.Tests;
+
+[Collection(EnvironmentVariableCollection.Name)]
+public sealed class NacosRouterIntegrationTests
+{
+ [Theory]
+ [InlineData(false, false, false)]
+ [InlineData(true, false, false)]
+ [InlineData(false, true, false)]
+ [InlineData(true, true, false)]
+ [InlineData(false, true, true)]
+ [InlineData(true, true, true)]
+ public async Task StaticDemo_UsesOnlyThreeRouterTools_AndHandlesProtocolFailures(bool maf, bool fail, bool plainText)
+ {
+ var state = new NacosRouterFixtureState { FailUse = fail, PlainTextFailure = plainText };
+ var builder = WebApplication.CreateSlimBuilder();
+ builder.WebHost.UseUrls("http://127.0.0.1:0");
+ builder.Services.AddSingleton(state);
+ builder.Services.AddMcpServer().WithHttpTransport(options => options.Stateless = true).WithTools();
+ await using var server = builder.Build();
+ server.MapMcp("/mcp");
+ await server.StartAsync(TestContext.Current.CancellationToken);
+ await using var registry = new McpServerToolRegistry(new McpPluginsConfig(), NullLogger.Instance);
+ var config = ServerConfig(server.Urls.Single() + "/mcp");
+ var reload = await registry.ReloadWorkspaceServersAsync(config, TestContext.Current.CancellationToken);
+ Assert.Equal(new[] { "nacos_mcp_router_add_mcp_server", "nacos_mcp_router_search_mcp_server", "nacos_mcp_router_use_tool" }, reload.AddedTools.Select(t => t.Name).Order().ToArray());
+ var search = await reload.AddedTools.Single(t => t.Name.EndsWith("_search_mcp_server")).ExecuteAsync("""{"task_description":"weather city","key_words":"weather,city"}""", TestContext.Current.CancellationToken);
+ Assert.Contains("weather-mcp", search);
+ Assert.DoesNotContain("score", search);
+ if (fail)
+ {
+ var direct = await reload.AddedTools.Single(t => t.Name.EndsWith("_use_tool")).ExecuteAsync(
+ """{"mcp_server_name":"weather-mcp","mcp_tool_name":"get_weather","params":{"city":"Oslo"}}""", TestContext.Current.CancellationToken);
+ Assert.Equal(plainText ? "failed to use tool: get_weather" : "Error: failed to use tool: get_weather", direct);
+ }
+ state.Calls.Clear();
+ var skill = LoadDemo();
+ var root = Path.Join(Path.GetTempPath(), "nacos-poc-tests", Guid.NewGuid().ToString("N"));
+ using var memory = new FileMemoryStore(root, 4);
+ using var services = new ServiceCollection().BuildServiceProvider();
+ var chat = Substitute.For();
+ var execution = Substitute.For();
+ var tools = reload.AddedTools.Append(new EmitTextTool()).ToArray();
+ var gatewayConfig = new GatewayConfig { Memory = new MemoryConfig { StoragePath = root } };
+ object runtime;
+ if (maf)
+ {
+ var options = new MafOptions();
+ runtime = new MafAgentRuntime(new AgentRuntimeFactoryContext
+ {
+ Services = services, Config = gatewayConfig,
+ RuntimeState = new GatewayRuntimeState { RequestedMode = "jit", EffectiveMode = GatewayRuntimeMode.Jit, DynamicCodeSupported = true },
+ ChatClient = chat, Tools = tools, MemoryStore = memory,
+ RuntimeMetrics = new RuntimeMetrics(), ProviderUsage = new ProviderUsageTracker(),
+ LlmExecutionService = execution, Skills = [skill], SkillsConfig = new SkillsConfig(),
+ WorkspacePath = null, PluginSkillDirs = [], Logger = NullLogger.Instance,
+ Hooks = [], RequireToolApproval = false, ApprovalRequiredTools = []
+ }, options, new MafAgentFactory(Options.Create(options), NullLoggerFactory.Instance, services),
+ new MafSessionStateStore(gatewayConfig, Options.Create(options), NullLogger.Instance),
+ new MafTelemetryAdapter(), NullLogger.Instance);
+ }
+ else runtime = new AgentRuntime(chat, tools, memory, gatewayConfig.Llm, maxHistoryTurns: 5, skills: [skill]);
+ try
+ {
+ for (var i = 0; i < 2; i++)
+ {
+ var session = new Session { Id = "nacos-poc-" + i, SenderId = "test", ChannelId = "test" };
+ var method = runtime.GetType().GetMethod("ExecuteMetaSkillAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ var result = await (Task)method.Invoke(runtime, [session, skill.Name, "Oslo", TestContext.Current.CancellationToken])!;
+ Assert.Equal(fail ? (plainText ? "failed to use tool: get_weather" : "weather-mcp unavailable; check Nacos registration and Router logs.") : "Weather for Oslo: sunny", result);
+ var run = Assert.Single(session.MetaRunHistory);
+ var query = Assert.Single(run.StepResults, step => step.Id == "query");
+ if (fail && !plainText) Assert.Equal("mcp_tool_error", query.FailureCode);
+ else Assert.Equal("completed", query.Status);
+ }
+ Assert.Equal(new[] { "add:weather-mcp", "use:weather-mcp:get_weather", "add:weather-mcp", "use:weather-mcp:get_weather" }, state.Calls);
+ Assert.Empty(chat.ReceivedCalls());
+ Assert.Empty(execution.ReceivedCalls());
+ var unchanged = await registry.ReloadWorkspaceServersAsync(config, TestContext.Current.CancellationToken);
+ Assert.Empty(unchanged.AddedTools);
+ Assert.Empty(unchanged.RemovedToolNames);
+ }
+ finally
+ {
+ if (runtime is IAsyncDisposable asyncDisposable) await asyncDisposable.DisposeAsync();
+ else if (runtime is IDisposable disposable) disposable.Dispose();
+ memory.Dispose();
+ if (Directory.Exists(root)) Directory.Delete(root, true);
+ }
+ }
+
+ public static bool LiveEnabled => Environment.GetEnvironmentVariable("OPENCLAW_NACOS_LIVE") == "1";
+
+ [Fact(Skip = "Set OPENCLAW_NACOS_LIVE=1 and OPENCLAW_NACOS_ROUTER_URL for a provisioned Router.", SkipUnless = nameof(LiveEnabled))]
+ public async Task LiveRouter_SearchFindsRegisteredWeatherServer()
+ {
+ var url = Environment.GetEnvironmentVariable("OPENCLAW_NACOS_ROUTER_URL");
+ Assert.True(Uri.TryCreate(url, UriKind.Absolute, out var endpoint) && endpoint.Scheme is "http" or "https",
+ "OPENCLAW_NACOS_ROUTER_URL must be an HTTP(S) MCP endpoint.");
+ await using var registry = new McpServerToolRegistry(new McpPluginsConfig(), NullLogger.Instance);
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(30));
+ var reload = await registry.ReloadWorkspaceServersAsync(ServerConfig(url!), timeout.Token);
+ var search = Assert.Single(reload.AddedTools, tool => tool.Name == "nacos_mcp_router_search_mcp_server");
+ var result = await search.ExecuteAsync("""{"task_description":"weather city","key_words":"weather,city"}""", timeout.Token);
+ Assert.Contains("weather-mcp", result);
+ Assert.DoesNotContain("Error:", result);
+ }
+
+ private static Dictionary ServerConfig(string url) => new()
+ {
+ ["nacos-mcp-router"] = new McpServerConfig { Enabled = true, Transport = "http", Url = url, ToolNamePrefix = "nacos_mcp_router_" }
+ };
+
+ private static SkillDefinition LoadDemo()
+ {
+ var root = new DirectoryInfo(AppContext.BaseDirectory);
+ while (root is not null && !File.Exists(Path.Join(root.FullName, "OpenClaw.Net.slnx"))) root = root.Parent;
+ Assert.NotNull(root);
+ var skills = SkillLoader.LoadAll(new SkillsConfig
+ {
+ Load = new SkillLoadConfig { IncludeBundled = false, IncludeManaged = false, IncludeWorkspace = false,
+ ExtraDirs = [Path.Join(root.FullName, "examples", "skills", "nacos-router-weather"),
+ Path.Join(root.FullName, "examples", "skills", "nacos-router-weather-explore")] }
+ }, null, NullLogger.Instance);
+ Assert.Equal(SkillKind.Standard, Assert.Single(skills, skill => skill.Name == "nacos-router-weather-explore").Kind);
+ return Assert.Single(skills, skill => skill.Name == "nacos-router-weather");
+ }
+}